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