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