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) error {
159 smtpServer := account.GetSMTPServer()
160 smtpPort := account.GetSMTPPort()
161
162 if smtpServer == "" {
163 return 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.FetchEmail
170 if account.Name != "" {
171 fromHeader = fmt.Sprintf("%s <%s>", account.Name, account.FetchEmail)
172 }
173
174 // Set top-level headers (From/To/Subject/Date/etc)
175 headers := map[string]string{
176 "From": fromHeader,
177 "To": strings.Join(to, ", "),
178 "Subject": subject,
179 "Date": time.Now().Format(time.RFC1123Z),
180 "Message-ID": generateMessageID(account.FetchEmail),
181 "MIME-Version": "1.0",
182 }
183
184 if len(cc) > 0 {
185 headers["Cc"] = strings.Join(cc, ", ")
186 }
187
188 if inReplyTo != "" {
189 headers["In-Reply-To"] = inReplyTo
190 if len(references) > 0 {
191 headers["References"] = strings.Join(references, " ") + " " + inReplyTo
192 } else {
193 headers["References"] = inReplyTo
194 }
195 }
196
197 // prepare final message buffer and S/MIME payload placeholder
198 var msg bytes.Buffer
199 headerOrder := []string{"From", "To", "Cc", "Subject", "Date", "Message-ID", "MIME-Version", "In-Reply-To", "References"}
200 for _, k := range headerOrder {
201 if v, ok := headers[k]; ok {
202 fmt.Fprintf(&msg, "%s: %s\r\n", k, v)
203 }
204 }
205
206 var payloadToEncrypt []byte
207 var innerBodyBytes []byte
208 var err error
209
210 // Detect plaintext-only mode
211 plaintextOnly := detectPlaintextOnly(plainBody, images, attachments)
212
213 // If plaintext-only mode is requested, build a single text/plain part (or a multipart/signed wrapper when signing)
214 if plaintextOnly {
215 if len(images) > 0 || len(attachments) > 0 {
216 return errors.New("plaintext-only messages cannot contain attachments or inline images")
217 }
218
219 // Build quoted-printable encoded body
220 var encBody bytes.Buffer
221 qp := quotedprintable.NewWriter(&encBody)
222 fmt.Fprint(qp, plainBody)
223 qp.Close()
224 encodedBody := encBody.Bytes()
225
226 // Build the canonical MIME part (headers + body) used for signing/encryption
227 var partBuf bytes.Buffer
228 fmt.Fprintf(&partBuf, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
229 fmt.Fprintf(&partBuf, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
230 partBuf.Write(encodedBody)
231 canonicalPart := partBuf.Bytes()
232
233 if signSMIME {
234 if account.SMIMECert == "" || account.SMIMEKey == "" {
235 return errors.New("S/MIME certificate or key path is missing")
236 }
237
238 certData, err := os.ReadFile(account.SMIMECert)
239 if err != nil {
240 return err
241 }
242 keyData, err := os.ReadFile(account.SMIMEKey)
243 if err != nil {
244 return err
245 }
246
247 certBlock, _ := pem.Decode(certData)
248 if certBlock == nil {
249 return errors.New("failed to parse certificate PEM")
250 }
251 cert, err := x509.ParseCertificate(certBlock.Bytes)
252 if err != nil {
253 return err
254 }
255
256 keyBlock, _ := pem.Decode(keyData)
257 if keyBlock == nil {
258 return errors.New("failed to parse private key PEM")
259 }
260 privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
261 if err != nil {
262 privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
263 if err != nil {
264 return err
265 }
266 }
267
268 // canonicalize the part (normalize newlines)
269 canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
270 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
271
272 signedData, err := pkcs7.NewSignedData(canonicalBody)
273 if err != nil {
274 return err
275 }
276 if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
277 return err
278 }
279 detachedSig, err := signedData.Finish()
280 if err != nil {
281 return err
282 }
283
284 var rb [12]byte
285 var outerBoundary string
286 if _, rerr := rand.Read(rb[:]); rerr == nil {
287 outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:])
288 } else {
289 // fallback to time-based boundary if crypto/rand fails
290 outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano())
291 }
292 var signedMsg bytes.Buffer
293 fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
294 fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
295 fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
296 signedMsg.Write(canonicalBody)
297 fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
298 fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
299 fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
300 fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
301 signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
302 fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
303
304 if encryptSMIME {
305 payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
306 payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
307 } else {
308 msg.Write(signedMsg.Bytes())
309 }
310 } else {
311 // Not signing: either encrypt the canonical part or send as plain single-part
312 canonicalBody := bytes.ReplaceAll(canonicalPart, []byte("\r\n"), []byte("\n"))
313 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
314 if encryptSMIME {
315 payloadToEncrypt = canonicalBody
316 } else {
317 // Write Content-Type and body as top-level single part
318 fmt.Fprintf(&msg, "Content-Type: text/plain; charset=UTF-8; format=flowed\r\n")
319 fmt.Fprintf(&msg, "Content-Transfer-Encoding: quoted-printable\r\n\r\n")
320 msg.Write(encodedBody)
321 }
322 }
323
324 } else {
325 // --- Non-plaintext path: build multipart/mixed with related/alternative, images and attachments ---
326 var innerMsg bytes.Buffer
327 innerWriter := multipart.NewWriter(&innerMsg)
328 innerHeaders := fmt.Sprintf("Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
329
330 // --- Body Part (multipart/related) ---
331 relatedHeader := textproto.MIMEHeader{}
332 relatedBoundary := "related-" + innerWriter.Boundary()
333 relatedHeader.Set("Content-Type", "multipart/related; boundary=\""+relatedBoundary+"\"")
334 relatedPartWriter, err := innerWriter.CreatePart(relatedHeader)
335 if err != nil {
336 return err
337 }
338 relatedWriter := multipart.NewWriter(relatedPartWriter)
339 relatedWriter.SetBoundary(relatedBoundary)
340
341 // --- Alternative Part (text and html) ---
342 altHeader := textproto.MIMEHeader{}
343 altBoundary := "alt-" + innerWriter.Boundary()
344 altHeader.Set("Content-Type", "multipart/alternative; boundary=\""+altBoundary+"\"")
345 altPartWriter, err := relatedWriter.CreatePart(altHeader)
346 if err != nil {
347 return err
348 }
349 altWriter := multipart.NewWriter(altPartWriter)
350 altWriter.SetBoundary(altBoundary)
351
352 // Plain text part
353 textHeader := textproto.MIMEHeader{
354 "Content-Type": {"text/plain; charset=UTF-8"},
355 "Content-Transfer-Encoding": {"quoted-printable"},
356 }
357 textPart, err := altWriter.CreatePart(textHeader)
358 if err != nil {
359 return err
360 }
361 qpText := quotedprintable.NewWriter(textPart)
362 fmt.Fprint(qpText, plainBody)
363 qpText.Close()
364
365 // HTML part
366 htmlHeader := textproto.MIMEHeader{
367 "Content-Type": {"text/html; charset=UTF-8"},
368 "Content-Transfer-Encoding": {"quoted-printable"},
369 }
370 htmlPart, err := altWriter.CreatePart(htmlHeader)
371 if err != nil {
372 return err
373 }
374 qpHTML := quotedprintable.NewWriter(htmlPart)
375 fmt.Fprint(qpHTML, htmlBody)
376 qpHTML.Close()
377
378 altWriter.Close() // Finish the alternative part
379
380 // --- Inline Images ---
381 for cid, data := range images {
382 ext := filepath.Ext(strings.Split(cid, "@")[0])
383 mimeType := mime.TypeByExtension(ext)
384 if mimeType == "" {
385 mimeType = "application/octet-stream"
386 }
387
388 imgHeader := textproto.MIMEHeader{}
389 imgHeader.Set("Content-Type", mimeType)
390 imgHeader.Set("Content-Transfer-Encoding", "base64")
391 imgHeader.Set("Content-ID", "<"+cid+">")
392 imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"")
393
394 imgPart, err := relatedWriter.CreatePart(imgHeader)
395 if err != nil {
396 return err
397 }
398 // Encode raw image bytes to base64, then wrap at 76 chars per MIME rules
399 encodedImg := base64.StdEncoding.EncodeToString(data)
400 imgPart.Write([]byte(clib.WrapBase64(encodedImg)))
401 }
402
403 relatedWriter.Close() // Finish the related part
404
405 // --- Attachments ---
406 for filename, data := range attachments {
407 mimeType := mime.TypeByExtension(filepath.Ext(filename))
408 if mimeType == "" {
409 mimeType = "application/octet-stream"
410 }
411
412 partHeader := textproto.MIMEHeader{}
413 partHeader.Set("Content-Type", mimeType)
414 partHeader.Set("Content-Transfer-Encoding", "base64")
415 partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
416
417 attachmentPart, err := innerWriter.CreatePart(partHeader)
418 if err != nil {
419 return err
420 }
421 encodedData := base64.StdEncoding.EncodeToString(data)
422 // MIME requires base64 to be line-wrapped at 76 characters
423 attachmentPart.Write([]byte(clib.WrapBase64(encodedData)))
424 }
425
426 innerWriter.Close() // Finish the inner message
427
428 innerBodyBytes = append([]byte(innerHeaders), innerMsg.Bytes()...)
429
430 // If not signing, and not encrypting, write the multipart body now
431 if !signSMIME && !encryptSMIME {
432 fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", innerWriter.Boundary())
433 msg.Write(innerMsg.Bytes())
434 }
435 }
436
437 // Handle S/MIME Detached Signing for non-plaintext messages
438 if signSMIME && len(innerBodyBytes) > 0 {
439 if account.SMIMECert == "" || account.SMIMEKey == "" {
440 return errors.New("S/MIME certificate or key path is missing")
441 }
442
443 certData, err := os.ReadFile(account.SMIMECert)
444 if err != nil {
445 return err
446 }
447 keyData, err := os.ReadFile(account.SMIMEKey)
448 if err != nil {
449 return err
450 }
451
452 certBlock, _ := pem.Decode(certData)
453 if certBlock == nil {
454 return errors.New("failed to parse certificate PEM")
455 }
456 cert, err := x509.ParseCertificate(certBlock.Bytes)
457 if err != nil {
458 return err
459 }
460
461 keyBlock, _ := pem.Decode(keyData)
462 if keyBlock == nil {
463 return errors.New("failed to parse private key PEM")
464 }
465 privKey, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
466 if err != nil {
467 privKey, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
468 if err != nil {
469 return err
470 }
471 }
472
473 canonicalBody := bytes.ReplaceAll(innerBodyBytes, []byte("\r\n"), []byte("\n"))
474 canonicalBody = bytes.ReplaceAll(canonicalBody, []byte("\n"), []byte("\r\n"))
475
476 signedData, err := pkcs7.NewSignedData(canonicalBody)
477 if err != nil {
478 return err
479 }
480 if err := signedData.AddSigner(cert, privKey, pkcs7.SignerInfoConfig{}); err != nil {
481 return err
482 }
483 detachedSig, err := signedData.Finish()
484 if err != nil {
485 return err
486 }
487
488 var rb [12]byte
489 var outerBoundary string
490 if _, rerr := rand.Read(rb[:]); rerr == nil {
491 outerBoundary = "signed-" + fmt.Sprintf("%x", rb[:])
492 } else {
493 // fallback to time-based boundary if crypto/rand fails
494 outerBoundary = "signed-" + fmt.Sprintf("%d", time.Now().UnixNano())
495 }
496 var signedMsg bytes.Buffer
497 fmt.Fprintf(&signedMsg, "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"sha-256\"; boundary=\"%s\"\r\n\r\n", outerBoundary)
498 fmt.Fprintf(&signedMsg, "This is a cryptographically signed message in MIME format.\r\n\r\n")
499 fmt.Fprintf(&signedMsg, "--%s\r\n", outerBoundary)
500 signedMsg.Write(canonicalBody)
501 fmt.Fprintf(&signedMsg, "\r\n--%s\r\n", outerBoundary)
502 fmt.Fprintf(&signedMsg, "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n")
503 fmt.Fprintf(&signedMsg, "Content-Transfer-Encoding: base64\r\n")
504 fmt.Fprintf(&signedMsg, "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n\r\n")
505 signedMsg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(detachedSig)))
506 fmt.Fprintf(&signedMsg, "\r\n--%s--\r\n", outerBoundary)
507
508 if encryptSMIME {
509 payloadToEncrypt = bytes.ReplaceAll(signedMsg.Bytes(), []byte("\r\n"), []byte("\n"))
510 payloadToEncrypt = bytes.ReplaceAll(payloadToEncrypt, []byte("\n"), []byte("\r\n"))
511 } else {
512 msg.Write(signedMsg.Bytes())
513 }
514 }
515
516 // Handle S/MIME Encryption
517 if encryptSMIME {
518 // Include the sender's own email so it can be decrypted in the Sent folder
519 allRecipients := append([]string{account.Email}, to...)
520 allRecipients = append(allRecipients, cc...)
521 allRecipients = append(allRecipients, bcc...)
522
523 cfgDir, _ := config.GetConfigDir()
524 certsDir := filepath.Join(cfgDir, "certs")
525 var certs []*x509.Certificate
526
527 for _, em := range allRecipients {
528 em = strings.TrimSpace(em)
529 if strings.Contains(em, "<") {
530 parts := strings.Split(em, "<")
531 if len(parts) == 2 {
532 em = strings.TrimSuffix(parts[1], ">")
533 }
534 }
535
536 var certPath string
537 // If this is our own account, use the path from settings rather than requiring it in the certs folder
538 if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
539 certPath = account.SMIMECert
540 } else {
541 certPath = filepath.Join(certsDir, em+".pem")
542 }
543
544 if certData, err := os.ReadFile(certPath); err == nil {
545 if block, _ := pem.Decode(certData); block != nil {
546 if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
547 certs = append(certs, cert)
548 }
549 }
550 }
551 }
552
553 if len(certs) == 0 {
554 return errors.New("cannot encrypt: no valid public certificates found for recipients")
555 }
556
557 encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
558 if err != nil {
559 return err
560 }
561
562 msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
563 msg.WriteString("Content-Transfer-Encoding: base64\r\n")
564 msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
565 msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
566 }
567
568 // Handle PGP Signing (if enabled and not already signed with S/MIME)
569 var pgpPayload []byte
570 if signPGP && !signSMIME {
571 // Determine what to sign
572 var toSign []byte
573 if len(payloadToEncrypt) > 0 {
574 // We have content prepared for encryption
575 toSign = payloadToEncrypt
576 } else {
577 // Use what we've built so far
578 toSign = msg.Bytes()
579 }
580
581 signed, err := signEmailPGP(toSign, account)
582 if err != nil {
583 return fmt.Errorf("PGP signing failed: %w", err)
584 }
585
586 if encryptPGP {
587 // Will encrypt the signed message
588 pgpPayload = signed
589 } else {
590 // Not encrypting, so write signed message now
591 msg.Reset()
592 msg.Write(signed)
593 }
594 }
595
596 // Handle PGP Encryption (if enabled and not already encrypted with S/MIME)
597 if encryptPGP && !encryptSMIME {
598 allRecipients := append([]string{}, to...)
599 allRecipients = append(allRecipients, cc...)
600 allRecipients = append(allRecipients, bcc...)
601
602 var toEncrypt []byte
603 if len(pgpPayload) > 0 {
604 // Encrypt the signed message
605 toEncrypt = pgpPayload
606 } else if len(payloadToEncrypt) > 0 {
607 // Encrypt pre-prepared payload
608 toEncrypt = payloadToEncrypt
609 } else {
610 // Encrypt what we've built so far
611 toEncrypt = msg.Bytes()
612 }
613
614 encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
615 if err != nil {
616 return fmt.Errorf("PGP encryption failed: %w", err)
617 }
618
619 msg.Reset()
620 msg.Write(encrypted)
621 }
622
623 // Combine all recipients for the envelope
624 allRecipients := append([]string{}, to...)
625 allRecipients = append(allRecipients, cc...)
626 allRecipients = append(allRecipients, bcc...)
627
628 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
629
630 tlsConfig := &tls.Config{
631 ServerName: smtpServer,
632 InsecureSkipVerify: account.Insecure,
633 }
634
635 var c *smtp.Client
636
637 // Port 465 uses implicit TLS (the connection starts with TLS).
638 // All other ports use plain TCP with optional STARTTLS upgrade.
639 if smtpPort == 465 {
640 conn, err := tls.Dial("tcp", addr, tlsConfig)
641 if err != nil {
642 return err
643 }
644 c, err = smtp.NewClient(conn, smtpServer)
645 if err != nil {
646 conn.Close()
647 return err
648 }
649 } else {
650 var err error
651 c, err = smtp.Dial(addr)
652 if err != nil {
653 return err
654 }
655 }
656 defer c.Close()
657
658 if err = c.Hello("localhost"); err != nil {
659 return err
660 }
661
662 // Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
663 if smtpPort != 465 {
664 if ok, _ := c.Extension("STARTTLS"); ok {
665 if err = c.StartTLS(tlsConfig); err != nil {
666 return err
667 }
668 }
669 }
670
671 // Authenticate using the best available mechanism.
672 // c.Extension("AUTH") returns the list of supported mechanisms.
673 if ok, mechs := c.Extension("AUTH"); ok {
674 mechList := strings.ToUpper(mechs)
675
676 if account.IsOAuth2() {
677 // Use XOAUTH2 for OAuth2-enabled accounts
678 token, tokenErr := config.GetOAuth2Token(account.Email)
679 if tokenErr != nil {
680 return fmt.Errorf("oauth2: %w", tokenErr)
681 }
682 err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
683 } else if strings.Contains(mechList, "PLAIN") {
684 err = c.Auth(plainAuth)
685 } else if strings.Contains(mechList, "LOGIN") {
686 err = c.Auth(loginAuthFallback)
687 } else {
688 // Fall back to PLAIN and let the server decide
689 err = c.Auth(plainAuth)
690 }
691 if err != nil {
692 return err
693 }
694 }
695
696 // Send Envelope
697 if err = c.Mail(account.FetchEmail); err != nil {
698 return err
699 }
700 for _, r := range allRecipients {
701 if err = c.Rcpt(r); err != nil {
702 return err
703 }
704 }
705
706 // Write Data
707 w, err := c.Data()
708 if err != nil {
709 return err
710 }
711 _, err = w.Write(msg.Bytes())
712 if err != nil {
713 return err
714 }
715 err = w.Close()
716 if err != nil {
717 return err
718 }
719
720 return c.Quit()
721}
722
723// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
724// Supports both file-based keys and YubiKey hardware tokens.
725func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
726 // Check if using YubiKey
727 if account.PGPKeySource == "yubikey" {
728 return signEmailPGPWithYubiKey(payload, account)
729 }
730
731 // Default to file-based signing
732 if account.PGPPrivateKey == "" {
733 return nil, errors.New("PGP private key path is missing")
734 }
735
736 // Load private key
737 keyFile, err := os.ReadFile(account.PGPPrivateKey)
738 if err != nil {
739 return nil, fmt.Errorf("failed to read PGP private key: %w", err)
740 }
741
742 // Try to parse as armored keyring first
743 entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
744 if err != nil {
745 // Try binary format
746 entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
747 if err != nil {
748 return nil, fmt.Errorf("failed to parse PGP key: %w", err)
749 }
750 }
751
752 if len(entityList) == 0 {
753 return nil, errors.New("no PGP keys found in keyring")
754 }
755
756 // Decrypt the private key if it's encrypted
757 entity := entityList[0]
758 if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
759 passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
760 if err := entity.DecryptPrivateKeys(passphrase); err != nil {
761 return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
762 }
763 }
764
765 // Split payload into transport headers (From, To, Subject, etc.) and body.
766 // pgpmail.Sign needs the transport headers in its header param so they
767 // appear at the top level of the output, not inside the signed part.
768 // Content headers (Content-Type, etc.) stay with the body as the signed part.
769 var header messagetextproto.Header
770 var bodyPayload []byte
771 if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
772 headerBytes := payload[:idx]
773 rawBody := payload[idx+4:]
774
775 var contentHeaders bytes.Buffer
776 for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
777 if len(line) == 0 {
778 continue
779 }
780 parts := bytes.SplitN(line, []byte(": "), 2)
781 if len(parts) != 2 {
782 continue
783 }
784 key := string(parts[0])
785 val := string(parts[1])
786 upper := strings.ToUpper(key)
787 if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
788 // Keep content headers with the body for the signed part
789 contentHeaders.Write(line)
790 contentHeaders.WriteString("\r\n")
791 } else {
792 // Transport headers go to the top-level message
793 header.Set(key, val)
794 }
795 }
796
797 // Reconstruct body payload: content headers + blank line + body
798 contentHeaders.WriteString("\r\n")
799 contentHeaders.Write(rawBody)
800 bodyPayload = contentHeaders.Bytes()
801 } else {
802 bodyPayload = payload
803 }
804
805 // Create multipart/signed message using go-pgpmail
806 var signed bytes.Buffer
807
808 mw, err := pgpmail.Sign(&signed, header, entity, nil)
809 if err != nil {
810 return nil, fmt.Errorf("failed to create PGP signer: %w", err)
811 }
812
813 // Write the body (content headers + body) to be signed
814 if _, err := mw.Write(bodyPayload); err != nil {
815 return nil, fmt.Errorf("failed to write message for signing: %w", err)
816 }
817
818 if err := mw.Close(); err != nil {
819 return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
820 }
821
822 return signed.Bytes(), nil
823}
824
825// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
826func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
827 // Get PIN from account (loaded from keyring)
828 pin := account.PGPPIN
829 if pin == "" {
830 return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
831 }
832
833 if account.PGPPublicKey == "" {
834 return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
835 }
836
837 // Use the pgp package to sign with YubiKey
838 signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
839 if err != nil {
840 return nil, fmt.Errorf("YubiKey signing failed: %w", err)
841 }
842 return signed, nil
843}
844
845// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
846func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
847 var entityList openpgp.EntityList
848
849 cfgDir, err := config.GetConfigDir()
850 if err != nil {
851 return nil, err
852 }
853 pgpDir := filepath.Join(cfgDir, "pgp")
854
855 // Add recipient keys
856 for _, recipient := range recipients {
857 // Extract email address from "Name <email>" format
858 email := strings.TrimSpace(recipient)
859 if strings.Contains(email, "<") {
860 parts := strings.Split(email, "<")
861 if len(parts) == 2 {
862 email = strings.TrimSuffix(parts[1], ">")
863 }
864 }
865
866 // Try .asc (armored) first, then .gpg (binary)
867 var keyData []byte
868 keyPath := filepath.Join(pgpDir, email+".asc")
869 keyData, err = os.ReadFile(keyPath)
870 if err != nil {
871 keyPath = filepath.Join(pgpDir, email+".gpg")
872 keyData, err = os.ReadFile(keyPath)
873 if err != nil {
874 return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
875 }
876 }
877
878 // Try armored format first
879 entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
880 if err != nil {
881 // Try binary format
882 entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
883 if err != nil {
884 return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
885 }
886 }
887
888 if len(entities) > 0 {
889 entityList = append(entityList, entities[0])
890 }
891 }
892
893 // Add sender's own key (to read in Sent folder)
894 if account.PGPPublicKey != "" {
895 senderKey, err := os.ReadFile(account.PGPPublicKey)
896 if err == nil {
897 entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
898 if entities == nil {
899 entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
900 }
901 if entities != nil && len(entities) > 0 {
902 entityList = append(entityList, entities[0])
903 }
904 }
905 }
906
907 if len(entityList) == 0 {
908 return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
909 }
910
911 // Encrypt using go-pgpmail
912 var encrypted bytes.Buffer
913
914 // Create a minimal header for the encrypted content
915 var header messagetextproto.Header
916
917 mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
918 if err != nil {
919 return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
920 }
921
922 if _, err := mw.Write(payload); err != nil {
923 return nil, fmt.Errorf("failed to write message for encryption: %w", err)
924 }
925
926 if err := mw.Close(); err != nil {
927 return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
928 }
929
930 return encrypted.Bytes(), nil
931}