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