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