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