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