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