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