sender.go

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