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/internal/loglevel"
  29	"github.com/floatpane/matcha/pgp"
  30	"github.com/yuin/goldmark"
  31	"github.com/yuin/goldmark/ast"
  32	"github.com/yuin/goldmark/text"
  33	"go.mozilla.org/pkcs7"
  34)
  35
  36// xoauth2Auth implements the SMTP XOAUTH2 authentication mechanism for OAuth2.
  37// See https://developers.google.com/gmail/imap/xoauth2-protocol
  38type xoauth2Auth struct {
  39	username, token string
  40}
  41
  42func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) {
  43	resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token)
  44	return "XOAUTH2", []byte(resp), nil
  45}
  46
  47func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
  48	if more {
  49		// Server sent an error challenge; respond with empty to finish.
  50		return []byte{}, nil
  51	}
  52	return nil, nil
  53}
  54
  55// loginAuth implements the SMTP LOGIN authentication mechanism.
  56// Some SMTP servers (e.g. Mailo) only support LOGIN and not PLAIN.
  57type loginAuth struct {
  58	username, password string
  59}
  60
  61func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
  62	return "LOGIN", nil, nil
  63}
  64
  65func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
  66	if !more {
  67		return nil, nil
  68	}
  69	prompt := strings.TrimSpace(string(fromServer))
  70	switch strings.ToLower(prompt) {
  71	case "username:":
  72		return []byte(a.username), nil
  73	case "password:":
  74		return []byte(a.password), nil
  75	default:
  76		return nil, fmt.Errorf("unexpected LOGIN prompt: %s", prompt)
  77	}
  78}
  79
  80// randReader is the source of randomness for boundary generation. It is a
  81// variable so tests can swap it with a deterministic or failing reader. By
  82// default it is crypto/rand.Reader.
  83var (
  84	randReader io.Reader = rand.Reader
  85	osHostname           = os.Hostname
  86)
  87
  88// smimeOuterBoundary returns a fresh, high-entropy MIME boundary for an S/MIME
  89// multipart/signed wrapper. If crypto/rand cannot supply randomness it returns
  90// an error rather than degrading to a predictable, time-based fallback.
  91func smimeOuterBoundary() (string, error) {
  92	var rb [12]byte
  93	if _, err := io.ReadFull(randReader, rb[:]); err != nil {
  94		return "", fmt.Errorf("smime: failed to read random bytes for outer boundary: %w", err)
  95	}
  96	return "signed-" + fmt.Sprintf("%x", rb[:]), nil
  97}
  98
  99// smtpHelloHostname returns the hostname used in the SMTP HELO/EHLO greeting.
 100// It falls back to localhost when the OS hostname cannot be read.
 101func smtpHelloHostname() string {
 102	hostname, err := osHostname()
 103	if err != nil || strings.TrimSpace(hostname) == "" {
 104		return "localhost"
 105	}
 106	return hostname
 107}
 108
 109// generateMessageID creates a unique Message-ID header.
 110func generateMessageID(from string) string {
 111	buf := make([]byte, 16)
 112	_, err := rand.Read(buf)
 113	if err != nil {
 114		return fmt.Sprintf("<%d.%s>", time.Now().UnixNano(), from)
 115	}
 116	return fmt.Sprintf("<%x@%s>", buf, from)
 117}
 118
 119// containsMarkup returns true if the string contains Markdown or HTML elements.
 120func containsMarkup(body string) bool {
 121	// Parse the Markdown into an AST. We will consider most AST node kinds as
 122	// markup, but treat bare/autolinks (raw URLs) as plaintext for this
 123	// detection: if a link node's visible text equals its destination (or is
 124	// the destination wrapped in <>), we allow it.
 125	source := []byte(body)
 126	md := goldmark.New()
 127	reader := text.NewReader(source)
 128	doc := md.Parser().Parse(reader)
 129
 130	var hasMarkup bool
 131	ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) { //nolint:errcheck,gosec
 132		if !entering {
 133			return ast.WalkContinue, nil
 134		}
 135
 136		switch node.Kind() {
 137		case ast.KindDocument, ast.KindParagraph, ast.KindText:
 138			// not considered formatting
 139			return ast.WalkContinue, nil
 140		case ast.KindLink:
 141			// Check if this is an autolink/raw URL: the link's text equals the
 142			// destination. If so, don't treat it as markup for our purposes.
 143			linkNode, ok := node.(*ast.Link)
 144			if !ok {
 145				hasMarkup = true
 146				return ast.WalkStop, nil
 147			}
 148
 149			// Collect the visible text of the link
 150			var b strings.Builder
 151			for c := node.FirstChild(); c != nil; c = c.NextSibling() {
 152				if txt, ok := c.(*ast.Text); ok {
 153					b.Write(txt.Segment.Value(source))
 154				} else {
 155					// non-text content inside link -> treat as markup
 156					hasMarkup = true
 157					return ast.WalkStop, nil
 158				}
 159			}
 160			linkText := b.String()
 161			dest := string(linkNode.Destination)
 162
 163			// Normalize common autolink representations and allow them.
 164			if linkText == dest || linkText == "<"+dest+">" {
 165				return ast.WalkContinue, nil
 166			}
 167
 168			// Otherwise treat as markup
 169			hasMarkup = true
 170			return ast.WalkStop, nil
 171		default:
 172			hasMarkup = true
 173			return ast.WalkStop, nil
 174		}
 175	})
 176	return hasMarkup
 177}
 178
 179// detectPlaintextOnly returns true when the body contains only plain text
 180// (no images, no attachments, no markdown/HTML formatting that requires multipart).
 181func detectPlaintextOnly(body string, images, attachments map[string][]byte) bool {
 182	if len(images) > 0 || len(attachments) > 0 {
 183		return false
 184	}
 185	return !containsMarkup(body)
 186}
 187
 188func writeQuotedPrintable(w io.Writer, body string) error {
 189	qp := quotedprintable.NewWriter(w)
 190	if _, err := fmt.Fprint(qp, body); err != nil {
 191		return fmt.Errorf("quoted-printable encoding failed: %w", err)
 192	}
 193	if err := qp.Close(); err != nil {
 194		return fmt.Errorf("quoted-printable encoding failed: %w", err)
 195	}
 196	return nil
 197}
 198
 199// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments.
 200func 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) { //nolint:gocyclo
 201	smtpServer := account.GetSMTPServer()
 202	smtpPort := account.GetSMTPPort()
 203
 204	if smtpServer == "" {
 205		return nil, fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
 206	}
 207
 208	plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
 209	loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
 210
 211	fromHeader := account.FormatFromHeader()
 212
 213	// Set top-level headers (From/To/Subject/Date/etc)
 214	headers := map[string]string{
 215		"From":         fromHeader,
 216		"To":           strings.Join(to, ", "),
 217		"Subject":      subject,
 218		"Date":         time.Now().Format(time.RFC1123Z),
 219		"Message-ID":   generateMessageID(account.GetSendAsEmail()),
 220		"MIME-Version": "1.0",
 221	}
 222
 223	if len(cc) > 0 {
 224		headers["Cc"] = strings.Join(cc, ", ")
 225	}
 226
 227	if inReplyTo != "" {
 228		headers["In-Reply-To"] = inReplyTo
 229		if len(references) > 0 {
 230			headers["References"] = strings.Join(references, " ") + " " + inReplyTo
 231		} else {
 232			headers["References"] = inReplyTo
 233		}
 234	}
 235
 236	// prepare final message buffer and S/MIME payload placeholder
 237	var msg bytes.Buffer
 238	headerOrder := []string{"From", "To", "Cc", "Subject", "Date", "Message-ID", "MIME-Version", "In-Reply-To", "References"}
 239	for _, k := range headerOrder {
 240		if v, ok := headers[k]; ok {
 241			fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
 242		}
 243	}
 244
 245	var payloadToEncrypt []byte
 246	var innerBodyBytes []byte
 247	var err error
 248
 249	// Detect plaintext-only mode
 250	plaintextOnly := detectPlaintextOnly(plainBody, images, attachments)
 251
 252	// If plaintext-only mode is requested, build a single text/plain part (or a multipart/signed wrapper when signing)
 253	if plaintextOnly {
 254		if len(images) > 0 || len(attachments) > 0 {
 255			return nil, errors.New("plaintext-only messages cannot contain attachments or inline images")
 256		}
 257
 258		// Build quoted-printable encoded body
 259		var encBody bytes.Buffer
 260		if err := writeQuotedPrintable(&encBody, plainBody); err != nil {
 261			return nil, err
 262		}
 263		encodedBody := encBody.Bytes()
 264
 265		// Build the canonical MIME part (headers + body) used for signing/encryption
 266		var partBuf bytes.Buffer
 267		fmt.Fprintf(&partBuf, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
 268		fmt.Fprintf(&partBuf, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
 269		partBuf.Write(encodedBody)
 270		canonicalPart := partBuf.Bytes()
 271
 272		if signSMIME {
 273			if account.SMIMECert == "" || account.SMIMEKey == "" {
 274				return nil, errors.New("S/MIME certificate or key path is missing")
 275			}
 276
 277			certData, err := os.ReadFile(account.SMIMECert)
 278			if err != nil {
 279				return nil, err
 280			}
 281			keyData, err := os.ReadFile(account.SMIMEKey)
 282			if err != nil {
 283				return nil, err
 284			}
 285
 286			certBlock, _ := pem.Decode(certData)
 287			if certBlock == nil {
 288				return nil, errors.New("failed to parse certificate PEM")
 289			}
 290			cert, err := x509.ParseCertificate(certBlock.Bytes)
 291			if err != nil {
 292				return nil, err
 293			}
 294
 295			keyBlock, _ := pem.Decode(keyData)
 296			if keyBlock == nil {
 297				return nil, errors.New("failed to parse private key PEM")
 298			}
 299			privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
 300			if err != nil {
 301				privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
 302				if err != nil {
 303					return nil, err
 304				}
 305			}
 306
 307			// canonicalize the part (normalize newlines)
 308			canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
 309			canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
 310
 311			signedData, err := pkcs7.NewSignedData(canonicalBody)
 312			if err != nil {
 313				return nil, err
 314			}
 315			if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
 316				return nil, err
 317			}
 318			detachedSig, err := signedData.Finish()
 319			if err != nil {
 320				return nil, err
 321			}
 322
 323			outerBoundary, err := smimeOuterBoundary()
 324			if err != nil {
 325				return nil, err
 326			}
 327			var signedMsg bytes.Buffer
 328			fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
 329			fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
 330			fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
 331			signedMsg.Write(canonicalBody)
 332			fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
 333			fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
 334			fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
 335			fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
 336			signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
 337			fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
 338
 339			if encryptSMIME {
 340				payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
 341				payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
 342			} else {
 343				msg.Write(signedMsg.Bytes())
 344			}
 345		} else {
 346			// Not signing: either encrypt the canonical part or send as plain single-part
 347			canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
 348			canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
 349			if encryptSMIME {
 350				payloadToEncrypt = canonicalBody
 351			} else {
 352				// Write Content-Type and body as top-level single part
 353				fmt.Fprintf(&msg, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
 354				fmt.Fprintf(&msg, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
 355				msg.Write(encodedBody)
 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) //nolint:errcheck,gosec
 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) //nolint:errcheck,gosec
 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() //nolint:errcheck,gosec
 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))) //nolint:errcheck,gosec
 435		}
 436
 437		relatedWriter.Close() //nolint:errcheck,gosec
 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))) //nolint:errcheck,gosec
 458		}
 459
 460		innerWriter.Close() //nolint:errcheck,gosec
 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		switch {
 644		case len(pgpPayload) > 0:
 645			// Encrypt the signed message
 646			toEncrypt = pgpPayload
 647		case len(payloadToEncrypt) > 0:
 648			// Encrypt pre-prepared payload
 649			toEncrypt = payloadToEncrypt
 650		default:
 651			// Encrypt what we've built so far
 652			toEncrypt = msg.Bytes()
 653		}
 654
 655		encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
 656		if err != nil {
 657			return nil, fmt.Errorf("PGP encryption failed: %w", err)
 658		}
 659
 660		msg.Reset()
 661		msg.Write(encrypted)
 662	}
 663
 664	// Combine all recipients for the envelope
 665	allRecipients := append([]string{}, to...)
 666	allRecipients = append(allRecipients, cc...)
 667	allRecipients = append(allRecipients, bcc...)
 668
 669	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
 670
 671	tlsConfig := &tls.Config{
 672		ServerName:         smtpServer,
 673		InsecureSkipVerify: account.Insecure, //nolint:gosec
 674		MinVersion:         tls.VersionTLS12,
 675		ClientSessionCache: account.GetClientSessionCache(),
 676		VerifyConnection: func(cs tls.ConnectionState) error {
 677			loglevel.Debugf("SMTP TLS connection resumed: %t", cs.DidResume)
 678			return nil
 679		},
 680	}
 681
 682	var c *smtp.Client
 683
 684	// Port 465 uses implicit TLS (the connection starts with TLS).
 685	// All other ports use plain TCP with optional STARTTLS upgrade.
 686	if smtpPort == 465 {
 687		conn, err := tls.Dial("tcp", addr, tlsConfig) //nolint:noctx
 688		if err != nil {
 689			return nil, err
 690		}
 691		c, err = smtp.NewClient(conn, smtpServer)
 692		if err != nil {
 693			conn.Close() //nolint:errcheck,gosec
 694			return nil, err
 695		}
 696	} else {
 697		var err error
 698		c, err = smtp.Dial(addr)
 699		if err != nil {
 700			return nil, err
 701		}
 702	}
 703	defer c.Close() //nolint:errcheck
 704
 705	if err = c.Hello(smtpHelloHostname()); err != nil {
 706		return nil, err
 707	}
 708
 709	// Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
 710	if smtpPort != 465 {
 711		if ok, _ := c.Extension("STARTTLS"); ok {
 712			if err = c.StartTLS(tlsConfig); err != nil {
 713				return nil, err
 714			}
 715		}
 716	}
 717
 718	// Authenticate using the best available mechanism.
 719	// c.Extension("AUTH") returns the list of supported mechanisms.
 720	if ok, mechs := c.Extension("AUTH"); ok {
 721		mechList := strings.ToUpper(mechs)
 722
 723		switch {
 724		case account.IsOAuth2():
 725			// Use XOAUTH2 for OAuth2-enabled accounts
 726			token, tokenErr := config.GetOAuth2Token(account.Email)
 727			if tokenErr != nil {
 728				return nil, fmt.Errorf("oauth2: %w", tokenErr)
 729			}
 730			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
 731		case strings.Contains(mechList, "PLAIN"):
 732			err = c.Auth(plainAuth)
 733		case strings.Contains(mechList, "LOGIN"):
 734			err = c.Auth(loginAuthFallback)
 735		default:
 736			// Fall back to PLAIN and let the server decide
 737			err = c.Auth(plainAuth)
 738		}
 739		if err != nil {
 740			return nil, err
 741		}
 742	}
 743
 744	// Send Envelope
 745	if err = c.Mail(account.GetSendAsEmail()); err != nil {
 746		return nil, err
 747	}
 748	for _, r := range allRecipients {
 749		if err = c.Rcpt(r); err != nil {
 750			return nil, err
 751		}
 752	}
 753
 754	// Write Data
 755	w, err := c.Data()
 756	if err != nil {
 757		return nil, err
 758	}
 759	_, err = w.Write(msg.Bytes())
 760	if err != nil {
 761		return nil, err
 762	}
 763	err = w.Close()
 764	if err != nil {
 765		return nil, err
 766	}
 767
 768	rawMsg := make([]byte, len(msg.Bytes()))
 769	copy(rawMsg, msg.Bytes())
 770
 771	if err := c.Quit(); err != nil {
 772		return nil, err
 773	}
 774
 775	return rawMsg, nil
 776}
 777
 778// SendCalendarReply sends an iMIP (RFC 6047) calendar reply.
 779// Google Calendar requires:
 780// - multipart/alternative with text/plain + text/calendar; method=REPLY
 781// - text/calendar part must NOT be Content-Disposition: attachment
 782func SendCalendarReply(account *config.Account, to []string, subject, plainBody string, icsData []byte, inReplyTo string, references []string) ([]byte, error) { //nolint:gocyclo
 783	smtpServer := account.GetSMTPServer()
 784	smtpPort := account.GetSMTPPort()
 785
 786	if smtpServer == "" {
 787		return nil, fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
 788	}
 789
 790	plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
 791	loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
 792
 793	fromHeader := account.FormatFromHeader()
 794
 795	var msg bytes.Buffer
 796
 797	// Headers
 798	fmt.Fprintf(&msg, "From: %s\r\n", fromHeader)
 799	fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(to, ", "))
 800	fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
 801	fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
 802	fmt.Fprintf(&msg, "Message-ID: %s\r\n", generateMessageID(account.GetSendAsEmail()))
 803	fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
 804
 805	if inReplyTo != "" {
 806		fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo)
 807		if len(references) > 0 {
 808			fmt.Fprintf(&msg, "References: %s %s\r\n", strings.Join(references, " "), inReplyTo)
 809		} else {
 810			fmt.Fprintf(&msg, "References: %s\r\n", inReplyTo)
 811		}
 812	}
 813
 814	// Build multipart/mixed containing:
 815	//   multipart/alternative (text/plain + text/calendar inline)
 816	//   + attached .ics file
 817	// Gmail needs both the inline text/calendar AND the .ics attachment
 818	var outerMsg bytes.Buffer
 819	outerWriter := multipart.NewWriter(&outerMsg)
 820
 821	fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", outerWriter.Boundary())
 822
 823	// multipart/alternative part (text/plain + text/calendar)
 824	altHeader := textproto.MIMEHeader{}
 825	var altMsg bytes.Buffer
 826	altWriter := multipart.NewWriter(&altMsg)
 827	altHeader.Set("Content-Type", fmt.Sprintf("multipart/alternative; boundary=\"%s\"", altWriter.Boundary()))
 828
 829	altPart, err := outerWriter.CreatePart(altHeader)
 830	if err != nil {
 831		return nil, err
 832	}
 833
 834	// text/plain part
 835	plainHeader := textproto.MIMEHeader{}
 836	plainHeader.Set("Content-Type", "text/plain; charset=UTF-8")
 837	plainHeader.Set("Content-Transfer-Encoding", "quoted-printable")
 838	plainPart, err := altWriter.CreatePart(plainHeader)
 839	if err != nil {
 840		return nil, err
 841	}
 842	qp := quotedprintable.NewWriter(plainPart)
 843	if _, err := fmt.Fprint(qp, plainBody); err != nil {
 844		return nil, err
 845	}
 846	if err := qp.Close(); err != nil {
 847		return nil, err
 848	}
 849
 850	// text/calendar inline part (Outlook/Mac Mail use this)
 851	calHeader := textproto.MIMEHeader{}
 852	calHeader.Set("Content-Type", "text/calendar; charset=UTF-8; method=REPLY")
 853	calHeader.Set("Content-Transfer-Encoding", "base64")
 854	calPart, err := altWriter.CreatePart(calHeader)
 855	if err != nil {
 856		return nil, err
 857	}
 858	if _, err := calPart.Write([]byte(clib.WrapBase64(base64.StdEncoding.EncodeToString(icsData)))); err != nil {
 859		return nil, err
 860	}
 861
 862	if err := altWriter.Close(); err != nil {
 863		return nil, err
 864	}
 865	if _, err := altPart.Write(altMsg.Bytes()); err != nil {
 866		return nil, err
 867	}
 868
 869	// .ics file attachment (Gmail uses this)
 870	attachHeader := textproto.MIMEHeader{}
 871	attachHeader.Set("Content-Type", "application/ics; name=\"invite.ics\"")
 872	attachHeader.Set("Content-Disposition", "attachment; filename=\"invite.ics\"")
 873	attachHeader.Set("Content-Transfer-Encoding", "base64")
 874	attachPart, err := outerWriter.CreatePart(attachHeader)
 875	if err != nil {
 876		return nil, err
 877	}
 878	if _, err := attachPart.Write([]byte(clib.WrapBase64(base64.StdEncoding.EncodeToString(icsData)))); err != nil {
 879		return nil, err
 880	}
 881
 882	if err := outerWriter.Close(); err != nil {
 883		return nil, err
 884	}
 885	if _, err := msg.Write(outerMsg.Bytes()); err != nil {
 886		return nil, err
 887	}
 888
 889	// Send via SMTP
 890	addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
 891
 892	tlsConfig := &tls.Config{
 893		ServerName:         smtpServer,
 894		InsecureSkipVerify: account.Insecure, //nolint:gosec
 895		MinVersion:         tls.VersionTLS12,
 896		ClientSessionCache: account.GetClientSessionCache(),
 897		VerifyConnection: func(cs tls.ConnectionState) error {
 898			loglevel.Debugf("SMTP TLS connection resumed: %t", cs.DidResume)
 899			return nil
 900		},
 901	}
 902
 903	var c *smtp.Client
 904
 905	if smtpPort == 465 {
 906		conn, err := tls.Dial("tcp", addr, tlsConfig) //nolint:noctx
 907		if err != nil {
 908			return nil, err
 909		}
 910		c, err = smtp.NewClient(conn, smtpServer)
 911		if err != nil {
 912			conn.Close() //nolint:errcheck,gosec
 913			return nil, err
 914		}
 915	} else {
 916		var err error
 917		c, err = smtp.Dial(addr)
 918		if err != nil {
 919			return nil, err
 920		}
 921	}
 922	defer c.Close() //nolint:errcheck
 923
 924	if err = c.Hello(smtpHelloHostname()); err != nil {
 925		return nil, err
 926	}
 927
 928	if smtpPort != 465 {
 929		if ok, _ := c.Extension("STARTTLS"); ok {
 930			if err = c.StartTLS(tlsConfig); err != nil {
 931				return nil, err
 932			}
 933		}
 934	}
 935
 936	if ok, mechs := c.Extension("AUTH"); ok {
 937		mechList := strings.ToUpper(mechs)
 938		switch {
 939		case account.IsOAuth2():
 940			token, tokenErr := config.GetOAuth2Token(account.Email)
 941			if tokenErr != nil {
 942				return nil, fmt.Errorf("oauth2: %w", tokenErr)
 943			}
 944			err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
 945		case strings.Contains(mechList, "PLAIN"):
 946			err = c.Auth(plainAuth)
 947		case strings.Contains(mechList, "LOGIN"):
 948			err = c.Auth(loginAuthFallback)
 949		default:
 950			err = c.Auth(plainAuth)
 951		}
 952		if err != nil {
 953			return nil, err
 954		}
 955	}
 956
 957	if err = c.Mail(account.GetSendAsEmail()); err != nil {
 958		return nil, err
 959	}
 960	for _, r := range to {
 961		if err = c.Rcpt(r); err != nil {
 962			return nil, err
 963		}
 964	}
 965
 966	w, err := c.Data()
 967	if err != nil {
 968		return nil, err
 969	}
 970	_, err = w.Write(msg.Bytes())
 971	if err != nil {
 972		return nil, err
 973	}
 974	err = w.Close()
 975	if err != nil {
 976		return nil, err
 977	}
 978
 979	rawMsg := make([]byte, len(msg.Bytes()))
 980	copy(rawMsg, msg.Bytes())
 981
 982	if err := c.Quit(); err != nil {
 983		return nil, err
 984	}
 985
 986	return rawMsg, nil
 987}
 988
 989// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
 990// Supports both file-based keys and YubiKey hardware tokens.
 991func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
 992	// Check if using YubiKey
 993	if account.PGPKeySource == "yubikey" {
 994		return signEmailPGPWithYubiKey(payload, account)
 995	}
 996
 997	// Default to file-based signing
 998	if account.PGPPrivateKey == "" {
 999		return nil, errors.New("PGP private key path is missing")
1000	}
1001
1002	// Load private key
1003	keyFile, err := os.ReadFile(account.PGPPrivateKey)
1004	if err != nil {
1005		return nil, fmt.Errorf("failed to read PGP private key: %w", err)
1006	}
1007
1008	// Try to parse as armored keyring first
1009	entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
1010	if err != nil {
1011		// Try binary format
1012		entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
1013		if err != nil {
1014			return nil, fmt.Errorf("failed to parse PGP key: %w", err)
1015		}
1016	}
1017
1018	if len(entityList) == 0 {
1019		return nil, errors.New("no PGP keys found in keyring")
1020	}
1021
1022	// Decrypt the private key if it's encrypted
1023	entity := entityList[0]
1024	if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
1025		passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
1026		if err := entity.DecryptPrivateKeys(passphrase); err != nil {
1027			return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
1028		}
1029	}
1030
1031	// Split payload into transport headers (From, To, Subject, etc.) and body.
1032	// pgpmail.Sign needs the transport headers in its header param so they
1033	// appear at the top level of the output, not inside the signed part.
1034	// Content headers (Content-Type, etc.) stay with the body as the signed part.
1035	var header messagetextproto.Header
1036	var bodyPayload []byte
1037	if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
1038		headerBytes := payload[:idx]
1039		rawBody := payload[idx+4:]
1040
1041		var contentHeaders bytes.Buffer
1042		for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
1043			if len(line) == 0 {
1044				continue
1045			}
1046			parts := bytes.SplitN(line, []byte(": "), 2)
1047			if len(parts) != 2 {
1048				continue
1049			}
1050			key := string(parts[0])
1051			val := string(parts[1])
1052			upper := strings.ToUpper(key)
1053			if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
1054				// Keep content headers with the body for the signed part
1055				contentHeaders.Write(line)
1056				contentHeaders.WriteString("\r\n")
1057			} else {
1058				// Transport headers go to the top-level message
1059				header.Set(key, val)
1060			}
1061		}
1062
1063		// Reconstruct body payload: content headers + blank line + body
1064		contentHeaders.WriteString("\r\n")
1065		contentHeaders.Write(rawBody)
1066		bodyPayload = contentHeaders.Bytes()
1067	} else {
1068		bodyPayload = payload
1069	}
1070
1071	// Create multipart/signed message using go-pgpmail
1072	var signed bytes.Buffer
1073
1074	mw, err := pgpmail.Sign(&signed, header, entity, nil)
1075	if err != nil {
1076		return nil, fmt.Errorf("failed to create PGP signer: %w", err)
1077	}
1078
1079	// Write the body (content headers + body) to be signed
1080	if _, err := mw.Write(bodyPayload); err != nil {
1081		return nil, fmt.Errorf("failed to write message for signing: %w", err)
1082	}
1083
1084	if err := mw.Close(); err != nil {
1085		return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
1086	}
1087
1088	return signed.Bytes(), nil
1089}
1090
1091// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
1092func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
1093	// Get PIN from account (loaded from keyring)
1094	pin := account.PGPPIN
1095	if pin == "" {
1096		return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
1097	}
1098
1099	if account.PGPPublicKey == "" {
1100		return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
1101	}
1102
1103	// Use the pgp package to sign with YubiKey
1104	signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
1105	if err != nil {
1106		return nil, fmt.Errorf("YubiKey signing failed: %w", err)
1107	}
1108	return signed, nil
1109}
1110
1111// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
1112func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
1113	var entityList openpgp.EntityList
1114
1115	cfgDir, err := config.GetConfigDir()
1116	if err != nil {
1117		return nil, err
1118	}
1119	pgpDir := filepath.Join(cfgDir, "pgp")
1120
1121	// Add recipient keys
1122	for _, recipient := range recipients {
1123		// Extract email address from "Name <email>" format
1124		email := strings.TrimSpace(recipient)
1125		if strings.Contains(email, "<") {
1126			parts := strings.Split(email, "<")
1127			if len(parts) == 2 {
1128				email = strings.TrimSuffix(parts[1], ">")
1129			}
1130		}
1131
1132		// Try .asc (armored) first, then .gpg (binary)
1133		var keyData []byte
1134		keyPath := filepath.Join(pgpDir, email+".asc")
1135		keyData, err = os.ReadFile(keyPath)
1136		if err != nil {
1137			keyPath = filepath.Join(pgpDir, email+".gpg")
1138			keyData, err = os.ReadFile(keyPath)
1139			if err != nil {
1140				return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
1141			}
1142		}
1143
1144		// Try armored format first
1145		entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
1146		if err != nil {
1147			// Try binary format
1148			entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
1149			if err != nil {
1150				return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
1151			}
1152		}
1153
1154		if len(entities) > 0 {
1155			entityList = append(entityList, entities[0])
1156		}
1157	}
1158
1159	// Add sender's own key (to read in Sent folder)
1160	if account.PGPPublicKey != "" {
1161		senderKey, err := os.ReadFile(account.PGPPublicKey)
1162		if err == nil {
1163			entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
1164			if entities == nil {
1165				entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
1166			}
1167			if len(entities) > 0 {
1168				entityList = append(entityList, entities[0])
1169			}
1170		}
1171	}
1172
1173	if len(entityList) == 0 {
1174		return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
1175	}
1176
1177	// Encrypt using go-pgpmail
1178	var encrypted bytes.Buffer
1179
1180	// Create a minimal header for the encrypted content
1181	var header messagetextproto.Header
1182
1183	mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
1184	if err != nil {
1185		return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
1186	}
1187
1188	if _, err := mw.Write(payload); err != nil {
1189		return nil, fmt.Errorf("failed to write message for encryption: %w", err)
1190	}
1191
1192	if err := mw.Close(); err != nil {
1193		return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
1194	}
1195
1196	return encrypted.Bytes(), nil
1197}