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