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