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