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