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
524		for _, em := range allRecipients {
525			em = strings.TrimSpace(em)
526			if strings.Contains(em, "<") {
527				parts := strings.Split(em, "<")
528				if len(parts) == 2 {
529					em = strings.TrimSuffix(parts[1], ">")
530				}
531			}
532
533			var certPath string
534			// If this is our own account, use the path from settings rather than requiring it in the certs folder
535			if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
536				certPath = account.SMIMECert
537			} else {
538				certPath = filepath.Join(certsDir, em+".pem")
539			}
540
541			if certData, err := os.ReadFile(certPath); err == nil {
542				if block, _ := pem.Decode(certData); block != nil {
543					if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
544						certs = append(certs, cert)
545					}
546				}
547			}
548		}
549
550		if len(certs) == 0 {
551			return nil, errors.New("cannot encrypt: no valid public certificates found for recipients")
552		}
553
554		encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
555		if err != nil {
556			return nil, err
557		}
558
559		msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
560		msg.WriteString("Content-Transfer-Encoding: base64\r\n")
561		msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
562		msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
563	}
564
565	// Handle PGP Signing (if enabled and not already signed with S/MIME)
566	var pgpPayload []byte
567	if signPGP && !signSMIME {
568		// Determine what to sign
569		var toSign []byte
570		if len(payloadToEncrypt) > 0 {
571			// We have content prepared for encryption
572			toSign = payloadToEncrypt
573		} else {
574			// Use what we've built so far
575			toSign = msg.Bytes()
576		}
577
578		signed, err := signEmailPGP(toSign, account)
579		if err != nil {
580			return nil, fmt.Errorf("PGP signing failed: %w", err)
581		}
582
583		if encryptPGP {
584			// Will encrypt the signed message
585			pgpPayload = signed
586		} else {
587			// Not encrypting, so write signed message now
588			msg.Reset()
589			msg.Write(signed)
590		}
591	}
592
593	// Handle PGP Encryption (if enabled and not already encrypted with S/MIME)
594	if encryptPGP && !encryptSMIME {
595		allRecipients := append([]string{}, to...)
596		allRecipients = append(allRecipients, cc...)
597		allRecipients = append(allRecipients, bcc...)
598
599		var toEncrypt []byte
600		if len(pgpPayload) > 0 {
601			// Encrypt the signed message
602			toEncrypt = pgpPayload
603		} else if len(payloadToEncrypt) > 0 {
604			// Encrypt pre-prepared payload
605			toEncrypt = payloadToEncrypt
606		} else {
607			// Encrypt what we've built so far
608			toEncrypt = msg.Bytes()
609		}
610
611		encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
612		if err != nil {
613			return nil, fmt.Errorf("PGP encryption failed: %w", err)
614		}
615
616		msg.Reset()
617		msg.Write(encrypted)
618	}
619
620	// Combine all recipients for the envelope
621	allRecipients := append([]string{}, to...)
622	allRecipients = append(allRecipients, cc...)
623	allRecipients = append(allRecipients, bcc...)
624
625	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
626
627	tlsConfig := &tls.Config{
628		ServerName:         smtpServer,
629		InsecureSkipVerify: account.Insecure,
630	}
631
632	var c *smtp.Client
633
634	// Port 465 uses implicit TLS (the connection starts with TLS).
635	// All other ports use plain TCP with optional STARTTLS upgrade.
636	if smtpPort == 465 {
637		conn, err := tls.Dial("tcp", addr, tlsConfig)
638		if err != nil {
639			return nil, err
640		}
641		c, err = smtp.NewClient(conn, smtpServer)
642		if err != nil {
643			conn.Close()
644			return nil, err
645		}
646	} else {
647		var err error
648		c, err = smtp.Dial(addr)
649		if err != nil {
650			return nil, err
651		}
652	}
653	defer c.Close()
654
655	if err = c.Hello("localhost"); err != nil {
656		return nil, err
657	}
658
659	// Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
660	if smtpPort != 465 {
661		if ok, _ := c.Extension("STARTTLS"); ok {
662			if err = c.StartTLS(tlsConfig); err != nil {
663				return nil, err
664			}
665		}
666	}
667
668	// Authenticate using the best available mechanism.
669	// c.Extension("AUTH") returns the list of supported mechanisms.
670	if ok, mechs := c.Extension("AUTH"); ok {
671		mechList := strings.ToUpper(mechs)
672
673		if account.IsOAuth2() {
674			// Use XOAUTH2 for OAuth2-enabled accounts
675			token, tokenErr := config.GetOAuth2Token(account.Email)
676			if tokenErr != nil {
677				return nil, fmt.Errorf("oauth2: %w", tokenErr)
678			}
679			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
680		} else if strings.Contains(mechList, "PLAIN") {
681			err = c.Auth(plainAuth)
682		} else if strings.Contains(mechList, "LOGIN") {
683			err = c.Auth(loginAuthFallback)
684		} else {
685			// Fall back to PLAIN and let the server decide
686			err = c.Auth(plainAuth)
687		}
688		if err != nil {
689			return nil, err
690		}
691	}
692
693	// Send Envelope
694	if err = c.Mail(account.GetFetchEmail()); err != nil {
695		return nil, err
696	}
697	for _, r := range allRecipients {
698		if err = c.Rcpt(r); err != nil {
699			return nil, err
700		}
701	}
702
703	// Write Data
704	w, err := c.Data()
705	if err != nil {
706		return nil, err
707	}
708	_, err = w.Write(msg.Bytes())
709	if err != nil {
710		return nil, err
711	}
712	err = w.Close()
713	if err != nil {
714		return nil, err
715	}
716
717	rawMsg := make([]byte, len(msg.Bytes()))
718	copy(rawMsg, msg.Bytes())
719
720	if err := c.Quit(); err != nil {
721		return nil, err
722	}
723
724	return rawMsg, nil
725}
726
727// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
728// Supports both file-based keys and YubiKey hardware tokens.
729func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
730	// Check if using YubiKey
731	if account.PGPKeySource == "yubikey" {
732		return signEmailPGPWithYubiKey(payload, account)
733	}
734
735	// Default to file-based signing
736	if account.PGPPrivateKey == "" {
737		return nil, errors.New("PGP private key path is missing")
738	}
739
740	// Load private key
741	keyFile, err := os.ReadFile(account.PGPPrivateKey)
742	if err != nil {
743		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
744	}
745
746	// Try to parse as armored keyring first
747	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
748	if err != nil {
749		// Try binary format
750		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
751		if err != nil {
752			return nil, fmt.Errorf("failed to parse PGP key: %w", err)
753		}
754	}
755
756	if len(entityList) == 0 {
757		return nil, errors.New("no PGP keys found in keyring")
758	}
759
760	// Decrypt the private key if it's encrypted
761	entity := entityList[0]
762	if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
763		passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
764		if err := entity.DecryptPrivateKeys(passphrase); err != nil {
765			return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
766		}
767	}
768
769	// Split payload into transport headers (From, To, Subject, etc.) and body.
770	// pgpmail.Sign needs the transport headers in its header param so they
771	// appear at the top level of the output, not inside the signed part.
772	// Content headers (Content-Type, etc.) stay with the body as the signed part.
773	var header messagetextproto.Header
774	var bodyPayload []byte
775	if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
776		headerBytes := payload[:idx]
777		rawBody := payload[idx+4:]
778
779		var contentHeaders bytes.Buffer
780		for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
781			if len(line) == 0 {
782				continue
783			}
784			parts := bytes.SplitN(line, []byte(": "), 2)
785			if len(parts) != 2 {
786				continue
787			}
788			key := string(parts[0])
789			val := string(parts[1])
790			upper := strings.ToUpper(key)
791			if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
792				// Keep content headers with the body for the signed part
793				contentHeaders.Write(line)
794				contentHeaders.WriteString("\r\n")
795			} else {
796				// Transport headers go to the top-level message
797				header.Set(key, val)
798			}
799		}
800
801		// Reconstruct body payload: content headers + blank line + body
802		contentHeaders.WriteString("\r\n")
803		contentHeaders.Write(rawBody)
804		bodyPayload = contentHeaders.Bytes()
805	} else {
806		bodyPayload = payload
807	}
808
809	// Create multipart/signed message using go-pgpmail
810	var signed bytes.Buffer
811
812	mw, err := pgpmail.Sign(&signed, header, entity, nil)
813	if err != nil {
814		return nil, fmt.Errorf("failed to create PGP signer: %w", err)
815	}
816
817	// Write the body (content headers + body) to be signed
818	if _, err := mw.Write(bodyPayload); err != nil {
819		return nil, fmt.Errorf("failed to write message for signing: %w", err)
820	}
821
822	if err := mw.Close(); err != nil {
823		return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
824	}
825
826	return signed.Bytes(), nil
827}
828
829// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
830func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
831	// Get PIN from account (loaded from keyring)
832	pin := account.PGPPIN
833	if pin == "" {
834		return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
835	}
836
837	if account.PGPPublicKey == "" {
838		return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
839	}
840
841	// Use the pgp package to sign with YubiKey
842	signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
843	if err != nil {
844		return nil, fmt.Errorf("YubiKey signing failed: %w", err)
845	}
846	return signed, nil
847}
848
849// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
850func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
851	var entityList openpgp.EntityList
852
853	cfgDir, err := config.GetConfigDir()
854	if err != nil {
855		return nil, err
856	}
857	pgpDir := filepath.Join(cfgDir, "pgp")
858
859	// Add recipient keys
860	for _, recipient := range recipients {
861		// Extract email address from "Name <email>" format
862		email := strings.TrimSpace(recipient)
863		if strings.Contains(email, "<") {
864			parts := strings.Split(email, "<")
865			if len(parts) == 2 {
866				email = strings.TrimSuffix(parts[1], ">")
867			}
868		}
869
870		// Try .asc (armored) first, then .gpg (binary)
871		var keyData []byte
872		keyPath := filepath.Join(pgpDir, email+".asc")
873		keyData, err = os.ReadFile(keyPath)
874		if err != nil {
875			keyPath = filepath.Join(pgpDir, email+".gpg")
876			keyData, err = os.ReadFile(keyPath)
877			if err != nil {
878				return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
879			}
880		}
881
882		// Try armored format first
883		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
884		if err != nil {
885			// Try binary format
886			entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
887			if err != nil {
888				return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
889			}
890		}
891
892		if len(entities) > 0 {
893			entityList = append(entityList, entities[0])
894		}
895	}
896
897	// Add sender's own key (to read in Sent folder)
898	if account.PGPPublicKey != "" {
899		senderKey, err := os.ReadFile(account.PGPPublicKey)
900		if err == nil {
901			entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
902			if entities == nil {
903				entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
904			}
905			if entities != nil && len(entities) > 0 {
906				entityList = append(entityList, entities[0])
907			}
908		}
909	}
910
911	if len(entityList) == 0 {
912		return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
913	}
914
915	// Encrypt using go-pgpmail
916	var encrypted bytes.Buffer
917
918	// Create a minimal header for the encrypted content
919	var header messagetextproto.Header
920
921	mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
922	if err != nil {
923		return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
924	}
925
926	if _, err := mw.Write(payload); err != nil {
927		return nil, fmt.Errorf("failed to write message for encryption: %w", err)
928	}
929
930	if err := mw.Close(); err != nil {
931		return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
932	}
933
934	return encrypted.Bytes(), nil
935}