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
524 for _, em := range allRecipients {
525 em = strings.TrimSpace(em)
526 if strings.Contains(em, "<") {
527 parts := strings.Split(em, "<")
528 if len(parts) == 2 {
529 em = strings.TrimSuffix(parts[1], ">")
530 }
531 }
532
533 var certPath string
534 // If this is our own account, use the path from settings rather than requiring it in the certs folder
535 if strings.EqualFold(em, account.Email) && account.SMIMECert != "" {
536 certPath = account.SMIMECert
537 } else {
538 certPath = filepath.Join(certsDir, em+".pem")
539 }
540
541 if certData, err := os.ReadFile(certPath); err == nil {
542 if block, _ := pem.Decode(certData); block != nil {
543 if cert, err := x509.ParseCertificate(block.Bytes); err == nil {
544 certs = append(certs, cert)
545 }
546 }
547 }
548 }
549
550 if len(certs) == 0 {
551 return nil, errors.New("cannot encrypt: no valid public certificates found for recipients")
552 }
553
554 encryptedDer, err := pkcs7.Encrypt(payloadToEncrypt, certs)
555 if err != nil {
556 return nil, err
557 }
558
559 msg.WriteString("Content-Type: application/pkcs7-mime; smime-type=enveloped-data; name=\"smime.p7m\"\r\n")
560 msg.WriteString("Content-Transfer-Encoding: base64\r\n")
561 msg.WriteString("Content-Disposition: attachment; filename=\"smime.p7m\"\r\n\r\n")
562 msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer)))
563 }
564
565 // Handle PGP Signing (if enabled and not already signed with S/MIME)
566 var pgpPayload []byte
567 if signPGP && !signSMIME {
568 // Determine what to sign
569 var toSign []byte
570 if len(payloadToEncrypt) > 0 {
571 // We have content prepared for encryption
572 toSign = payloadToEncrypt
573 } else {
574 // Use what we've built so far
575 toSign = msg.Bytes()
576 }
577
578 signed, err := signEmailPGP(toSign, account)
579 if err != nil {
580 return nil, fmt.Errorf("PGP signing failed: %w", err)
581 }
582
583 if encryptPGP {
584 // Will encrypt the signed message
585 pgpPayload = signed
586 } else {
587 // Not encrypting, so write signed message now
588 msg.Reset()
589 msg.Write(signed)
590 }
591 }
592
593 // Handle PGP Encryption (if enabled and not already encrypted with S/MIME)
594 if encryptPGP && !encryptSMIME {
595 allRecipients := append([]string{}, to...)
596 allRecipients = append(allRecipients, cc...)
597 allRecipients = append(allRecipients, bcc...)
598
599 var toEncrypt []byte
600 if len(pgpPayload) > 0 {
601 // Encrypt the signed message
602 toEncrypt = pgpPayload
603 } else if len(payloadToEncrypt) > 0 {
604 // Encrypt pre-prepared payload
605 toEncrypt = payloadToEncrypt
606 } else {
607 // Encrypt what we've built so far
608 toEncrypt = msg.Bytes()
609 }
610
611 encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account)
612 if err != nil {
613 return nil, fmt.Errorf("PGP encryption failed: %w", err)
614 }
615
616 msg.Reset()
617 msg.Write(encrypted)
618 }
619
620 // Combine all recipients for the envelope
621 allRecipients := append([]string{}, to...)
622 allRecipients = append(allRecipients, cc...)
623 allRecipients = append(allRecipients, bcc...)
624
625 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
626
627 tlsConfig := &tls.Config{
628 ServerName: smtpServer,
629 InsecureSkipVerify: account.Insecure,
630 }
631
632 var c *smtp.Client
633
634 // Port 465 uses implicit TLS (the connection starts with TLS).
635 // All other ports use plain TCP with optional STARTTLS upgrade.
636 if smtpPort == 465 {
637 conn, err := tls.Dial("tcp", addr, tlsConfig)
638 if err != nil {
639 return nil, err
640 }
641 c, err = smtp.NewClient(conn, smtpServer)
642 if err != nil {
643 conn.Close()
644 return nil, err
645 }
646 } else {
647 var err error
648 c, err = smtp.Dial(addr)
649 if err != nil {
650 return nil, err
651 }
652 }
653 defer c.Close()
654
655 if err = c.Hello("localhost"); err != nil {
656 return nil, err
657 }
658
659 // Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
660 if smtpPort != 465 {
661 if ok, _ := c.Extension("STARTTLS"); ok {
662 if err = c.StartTLS(tlsConfig); err != nil {
663 return nil, err
664 }
665 }
666 }
667
668 // Authenticate using the best available mechanism.
669 // c.Extension("AUTH") returns the list of supported mechanisms.
670 if ok, mechs := c.Extension("AUTH"); ok {
671 mechList := strings.ToUpper(mechs)
672
673 if account.IsOAuth2() {
674 // Use XOAUTH2 for OAuth2-enabled accounts
675 token, tokenErr := config.GetOAuth2Token(account.Email)
676 if tokenErr != nil {
677 return nil, fmt.Errorf("oauth2: %w", tokenErr)
678 }
679 err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
680 } else if strings.Contains(mechList, "PLAIN") {
681 err = c.Auth(plainAuth)
682 } else if strings.Contains(mechList, "LOGIN") {
683 err = c.Auth(loginAuthFallback)
684 } else {
685 // Fall back to PLAIN and let the server decide
686 err = c.Auth(plainAuth)
687 }
688 if err != nil {
689 return nil, err
690 }
691 }
692
693 // Send Envelope
694 if err = c.Mail(account.GetFetchEmail()); err != nil {
695 return nil, err
696 }
697 for _, r := range allRecipients {
698 if err = c.Rcpt(r); err != nil {
699 return nil, err
700 }
701 }
702
703 // Write Data
704 w, err := c.Data()
705 if err != nil {
706 return nil, err
707 }
708 _, err = w.Write(msg.Bytes())
709 if err != nil {
710 return nil, err
711 }
712 err = w.Close()
713 if err != nil {
714 return nil, err
715 }
716
717 rawMsg := make([]byte, len(msg.Bytes()))
718 copy(rawMsg, msg.Bytes())
719
720 if err := c.Quit(); err != nil {
721 return nil, err
722 }
723
724 return rawMsg, nil
725}
726
727// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
728// Supports both file-based keys and YubiKey hardware tokens.
729func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
730 // Check if using YubiKey
731 if account.PGPKeySource == "yubikey" {
732 return signEmailPGPWithYubiKey(payload, account)
733 }
734
735 // Default to file-based signing
736 if account.PGPPrivateKey == "" {
737 return nil, errors.New("PGP private key path is missing")
738 }
739
740 // Load private key
741 keyFile, err := os.ReadFile(account.PGPPrivateKey)
742 if err != nil {
743 return nil, fmt.Errorf("failed to read PGP private key: %w", err)
744 }
745
746 // Try to parse as armored keyring first
747 entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
748 if err != nil {
749 // Try binary format
750 entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
751 if err != nil {
752 return nil, fmt.Errorf("failed to parse PGP key: %w", err)
753 }
754 }
755
756 if len(entityList) == 0 {
757 return nil, errors.New("no PGP keys found in keyring")
758 }
759
760 // Decrypt the private key if it's encrypted
761 entity := entityList[0]
762 if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
763 passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
764 if err := entity.DecryptPrivateKeys(passphrase); err != nil {
765 return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
766 }
767 }
768
769 // Split payload into transport headers (From, To, Subject, etc.) and body.
770 // pgpmail.Sign needs the transport headers in its header param so they
771 // appear at the top level of the output, not inside the signed part.
772 // Content headers (Content-Type, etc.) stay with the body as the signed part.
773 var header messagetextproto.Header
774 var bodyPayload []byte
775 if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
776 headerBytes := payload[:idx]
777 rawBody := payload[idx+4:]
778
779 var contentHeaders bytes.Buffer
780 for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
781 if len(line) == 0 {
782 continue
783 }
784 parts := bytes.SplitN(line, []byte(": "), 2)
785 if len(parts) != 2 {
786 continue
787 }
788 key := string(parts[0])
789 val := string(parts[1])
790 upper := strings.ToUpper(key)
791 if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
792 // Keep content headers with the body for the signed part
793 contentHeaders.Write(line)
794 contentHeaders.WriteString("\r\n")
795 } else {
796 // Transport headers go to the top-level message
797 header.Set(key, val)
798 }
799 }
800
801 // Reconstruct body payload: content headers + blank line + body
802 contentHeaders.WriteString("\r\n")
803 contentHeaders.Write(rawBody)
804 bodyPayload = contentHeaders.Bytes()
805 } else {
806 bodyPayload = payload
807 }
808
809 // Create multipart/signed message using go-pgpmail
810 var signed bytes.Buffer
811
812 mw, err := pgpmail.Sign(&signed, header, entity, nil)
813 if err != nil {
814 return nil, fmt.Errorf("failed to create PGP signer: %w", err)
815 }
816
817 // Write the body (content headers + body) to be signed
818 if _, err := mw.Write(bodyPayload); err != nil {
819 return nil, fmt.Errorf("failed to write message for signing: %w", err)
820 }
821
822 if err := mw.Close(); err != nil {
823 return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
824 }
825
826 return signed.Bytes(), nil
827}
828
829// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
830func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
831 // Get PIN from account (loaded from keyring)
832 pin := account.PGPPIN
833 if pin == "" {
834 return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
835 }
836
837 if account.PGPPublicKey == "" {
838 return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
839 }
840
841 // Use the pgp package to sign with YubiKey
842 signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
843 if err != nil {
844 return nil, fmt.Errorf("YubiKey signing failed: %w", err)
845 }
846 return signed, nil
847}
848
849// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
850func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
851 var entityList openpgp.EntityList
852
853 cfgDir, err := config.GetConfigDir()
854 if err != nil {
855 return nil, err
856 }
857 pgpDir := filepath.Join(cfgDir, "pgp")
858
859 // Add recipient keys
860 for _, recipient := range recipients {
861 // Extract email address from "Name <email>" format
862 email := strings.TrimSpace(recipient)
863 if strings.Contains(email, "<") {
864 parts := strings.Split(email, "<")
865 if len(parts) == 2 {
866 email = strings.TrimSuffix(parts[1], ">")
867 }
868 }
869
870 // Try .asc (armored) first, then .gpg (binary)
871 var keyData []byte
872 keyPath := filepath.Join(pgpDir, email+".asc")
873 keyData, err = os.ReadFile(keyPath)
874 if err != nil {
875 keyPath = filepath.Join(pgpDir, email+".gpg")
876 keyData, err = os.ReadFile(keyPath)
877 if err != nil {
878 return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
879 }
880 }
881
882 // Try armored format first
883 entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
884 if err != nil {
885 // Try binary format
886 entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
887 if err != nil {
888 return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
889 }
890 }
891
892 if len(entities) > 0 {
893 entityList = append(entityList, entities[0])
894 }
895 }
896
897 // Add sender's own key (to read in Sent folder)
898 if account.PGPPublicKey != "" {
899 senderKey, err := os.ReadFile(account.PGPPublicKey)
900 if err == nil {
901 entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
902 if entities == nil {
903 entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
904 }
905 if entities != nil && len(entities) > 0 {
906 entityList = append(entityList, entities[0])
907 }
908 }
909 }
910
911 if len(entityList) == 0 {
912 return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
913 }
914
915 // Encrypt using go-pgpmail
916 var encrypted bytes.Buffer
917
918 // Create a minimal header for the encrypted content
919 var header messagetextproto.Header
920
921 mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
922 if err != nil {
923 return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
924 }
925
926 if _, err := mw.Write(payload); err != nil {
927 return nil, fmt.Errorf("failed to write message for encryption: %w", err)
928 }
929
930 if err := mw.Close(); err != nil {
931 return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
932 }
933
934 return encrypted.Bytes(), nil
935}