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