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 MinVersion: tls.VersionTLS12,
641 }
642
643 var c *smtp.Client
644
645 // Port 465 uses implicit TLS (the connection starts with TLS).
646 // All other ports use plain TCP with optional STARTTLS upgrade.
647 if smtpPort == 465 {
648 conn, err := tls.Dial("tcp", addr, tlsConfig)
649 if err != nil {
650 return nil, err
651 }
652 c, err = smtp.NewClient(conn, smtpServer)
653 if err != nil {
654 conn.Close()
655 return nil, err
656 }
657 } else {
658 var err error
659 c, err = smtp.Dial(addr)
660 if err != nil {
661 return nil, err
662 }
663 }
664 defer c.Close()
665
666 if err = c.Hello("localhost"); err != nil {
667 return nil, err
668 }
669
670 // Trigger STARTTLS if supported (not needed for implicit TLS on port 465)
671 if smtpPort != 465 {
672 if ok, _ := c.Extension("STARTTLS"); ok {
673 if err = c.StartTLS(tlsConfig); err != nil {
674 return nil, err
675 }
676 }
677 }
678
679 // Authenticate using the best available mechanism.
680 // c.Extension("AUTH") returns the list of supported mechanisms.
681 if ok, mechs := c.Extension("AUTH"); ok {
682 mechList := strings.ToUpper(mechs)
683
684 if account.IsOAuth2() {
685 // Use XOAUTH2 for OAuth2-enabled accounts
686 token, tokenErr := config.GetOAuth2Token(account.Email)
687 if tokenErr != nil {
688 return nil, fmt.Errorf("oauth2: %w", tokenErr)
689 }
690 err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
691 } else if strings.Contains(mechList, "PLAIN") {
692 err = c.Auth(plainAuth)
693 } else if strings.Contains(mechList, "LOGIN") {
694 err = c.Auth(loginAuthFallback)
695 } else {
696 // Fall back to PLAIN and let the server decide
697 err = c.Auth(plainAuth)
698 }
699 if err != nil {
700 return nil, err
701 }
702 }
703
704 // Send Envelope
705 if err = c.Mail(account.GetFetchEmail()); err != nil {
706 return nil, err
707 }
708 for _, r := range allRecipients {
709 if err = c.Rcpt(r); err != nil {
710 return nil, err
711 }
712 }
713
714 // Write Data
715 w, err := c.Data()
716 if err != nil {
717 return nil, err
718 }
719 _, err = w.Write(msg.Bytes())
720 if err != nil {
721 return nil, err
722 }
723 err = w.Close()
724 if err != nil {
725 return nil, err
726 }
727
728 rawMsg := make([]byte, len(msg.Bytes()))
729 copy(rawMsg, msg.Bytes())
730
731 if err := c.Quit(); err != nil {
732 return nil, err
733 }
734
735 return rawMsg, nil
736}
737
738// SendCalendarReply sends an iMIP (RFC 6047) calendar reply.
739// Google Calendar requires:
740// - multipart/alternative with text/plain + text/calendar; method=REPLY
741// - text/calendar part must NOT be Content-Disposition: attachment
742func SendCalendarReply(account *config.Account, to []string, subject, plainBody string, icsData []byte, inReplyTo string, references []string) ([]byte, error) {
743 smtpServer := account.GetSMTPServer()
744 smtpPort := account.GetSMTPPort()
745
746 if smtpServer == "" {
747 return nil, fmt.Errorf("unsupported or missing service_provider: %s", account.ServiceProvider)
748 }
749
750 plainAuth := smtp.PlainAuth("", account.Email, account.Password, smtpServer)
751 loginAuthFallback := &loginAuth{username: account.Email, password: account.Password}
752
753 fromHeader := account.FormatFromHeader()
754
755 var msg bytes.Buffer
756
757 // Headers
758 fmt.Fprintf(&msg, "From: %s\r\n", fromHeader)
759 fmt.Fprintf(&msg, "To: %s\r\n", strings.Join(to, ", "))
760 fmt.Fprintf(&msg, "Subject: %s\r\n", subject)
761 fmt.Fprintf(&msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
762 fmt.Fprintf(&msg, "Message-ID: %s\r\n", generateMessageID(account.GetSendAsEmail()))
763 fmt.Fprintf(&msg, "MIME-Version: 1.0\r\n")
764
765 if inReplyTo != "" {
766 fmt.Fprintf(&msg, "In-Reply-To: %s\r\n", inReplyTo)
767 if len(references) > 0 {
768 fmt.Fprintf(&msg, "References: %s %s\r\n", strings.Join(references, " "), inReplyTo)
769 } else {
770 fmt.Fprintf(&msg, "References: %s\r\n", inReplyTo)
771 }
772 }
773
774 // Build multipart/mixed containing:
775 // multipart/alternative (text/plain + text/calendar inline)
776 // + attached .ics file
777 // Gmail needs both the inline text/calendar AND the .ics attachment
778 var outerMsg bytes.Buffer
779 outerWriter := multipart.NewWriter(&outerMsg)
780
781 fmt.Fprintf(&msg, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n\r\n", outerWriter.Boundary())
782
783 // multipart/alternative part (text/plain + text/calendar)
784 altHeader := textproto.MIMEHeader{}
785 var altMsg bytes.Buffer
786 altWriter := multipart.NewWriter(&altMsg)
787 altHeader.Set("Content-Type", fmt.Sprintf("multipart/alternative; boundary=\"%s\"", altWriter.Boundary()))
788
789 altPart, err := outerWriter.CreatePart(altHeader)
790 if err != nil {
791 return nil, err
792 }
793
794 // text/plain part
795 plainHeader := textproto.MIMEHeader{}
796 plainHeader.Set("Content-Type", "text/plain; charset=UTF-8")
797 plainHeader.Set("Content-Transfer-Encoding", "quoted-printable")
798 plainPart, err := altWriter.CreatePart(plainHeader)
799 if err != nil {
800 return nil, err
801 }
802 qp := quotedprintable.NewWriter(plainPart)
803 fmt.Fprint(qp, plainBody)
804 qp.Close()
805
806 // text/calendar inline part (Outlook/Mac Mail use this)
807 calHeader := textproto.MIMEHeader{}
808 calHeader.Set("Content-Type", "text/calendar; charset=UTF-8; method=REPLY")
809 calHeader.Set("Content-Transfer-Encoding", "base64")
810 calPart, err := altWriter.CreatePart(calHeader)
811 if err != nil {
812 return nil, err
813 }
814 calPart.Write([]byte(clib.WrapBase64(base64.StdEncoding.EncodeToString(icsData))))
815
816 altWriter.Close()
817 altPart.Write(altMsg.Bytes())
818
819 // .ics file attachment (Gmail uses this)
820 attachHeader := textproto.MIMEHeader{}
821 attachHeader.Set("Content-Type", "application/ics; name=\"invite.ics\"")
822 attachHeader.Set("Content-Disposition", "attachment; filename=\"invite.ics\"")
823 attachHeader.Set("Content-Transfer-Encoding", "base64")
824 attachPart, err := outerWriter.CreatePart(attachHeader)
825 if err != nil {
826 return nil, err
827 }
828 attachPart.Write([]byte(clib.WrapBase64(base64.StdEncoding.EncodeToString(icsData))))
829
830 outerWriter.Close()
831 msg.Write(outerMsg.Bytes())
832
833 // Send via SMTP
834 addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort)
835 tlsConfig := &tls.Config{
836 ServerName: smtpServer,
837 InsecureSkipVerify: account.Insecure,
838 MinVersion: tls.VersionTLS12,
839 }
840
841 var c *smtp.Client
842
843 if smtpPort == 465 {
844 conn, err := tls.Dial("tcp", addr, tlsConfig)
845 if err != nil {
846 return nil, err
847 }
848 c, err = smtp.NewClient(conn, smtpServer)
849 if err != nil {
850 conn.Close()
851 return nil, err
852 }
853 } else {
854 var err error
855 c, err = smtp.Dial(addr)
856 if err != nil {
857 return nil, err
858 }
859 }
860 defer c.Close()
861
862 if err = c.Hello("localhost"); err != nil {
863 return nil, err
864 }
865
866 if smtpPort != 465 {
867 if ok, _ := c.Extension("STARTTLS"); ok {
868 if err = c.StartTLS(tlsConfig); err != nil {
869 return nil, err
870 }
871 }
872 }
873
874 if ok, mechs := c.Extension("AUTH"); ok {
875 mechList := strings.ToUpper(mechs)
876 if account.IsOAuth2() {
877 token, tokenErr := config.GetOAuth2Token(account.Email)
878 if tokenErr != nil {
879 return nil, fmt.Errorf("oauth2: %w", tokenErr)
880 }
881 err = c.Auth(&xoauth2Auth{username: account.Email, token: token})
882 } else if strings.Contains(mechList, "PLAIN") {
883 err = c.Auth(plainAuth)
884 } else if strings.Contains(mechList, "LOGIN") {
885 err = c.Auth(loginAuthFallback)
886 } else {
887 err = c.Auth(plainAuth)
888 }
889 if err != nil {
890 return nil, err
891 }
892 }
893
894 if err = c.Mail(account.GetFetchEmail()); err != nil {
895 return nil, err
896 }
897 for _, r := range to {
898 if err = c.Rcpt(r); err != nil {
899 return nil, err
900 }
901 }
902
903 w, err := c.Data()
904 if err != nil {
905 return nil, err
906 }
907 _, err = w.Write(msg.Bytes())
908 if err != nil {
909 return nil, err
910 }
911 err = w.Close()
912 if err != nil {
913 return nil, err
914 }
915
916 rawMsg := make([]byte, len(msg.Bytes()))
917 copy(rawMsg, msg.Bytes())
918
919 if err := c.Quit(); err != nil {
920 return nil, err
921 }
922
923 return rawMsg, nil
924}
925
926// signEmailPGP signs the message payload with PGP and returns a multipart/signed message.
927// Supports both file-based keys and YubiKey hardware tokens.
928func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) {
929 // Check if using YubiKey
930 if account.PGPKeySource == "yubikey" {
931 return signEmailPGPWithYubiKey(payload, account)
932 }
933
934 // Default to file-based signing
935 if account.PGPPrivateKey == "" {
936 return nil, errors.New("PGP private key path is missing")
937 }
938
939 // Load private key
940 keyFile, err := os.ReadFile(account.PGPPrivateKey)
941 if err != nil {
942 return nil, fmt.Errorf("failed to read PGP private key: %w", err)
943 }
944
945 // Try to parse as armored keyring first
946 entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
947 if err != nil {
948 // Try binary format
949 entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
950 if err != nil {
951 return nil, fmt.Errorf("failed to parse PGP key: %w", err)
952 }
953 }
954
955 if len(entityList) == 0 {
956 return nil, errors.New("no PGP keys found in keyring")
957 }
958
959 // Decrypt the private key if it's encrypted
960 entity := entityList[0]
961 if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
962 passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase
963 if err := entity.DecryptPrivateKeys(passphrase); err != nil {
964 return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err)
965 }
966 }
967
968 // Split payload into transport headers (From, To, Subject, etc.) and body.
969 // pgpmail.Sign needs the transport headers in its header param so they
970 // appear at the top level of the output, not inside the signed part.
971 // Content headers (Content-Type, etc.) stay with the body as the signed part.
972 var header messagetextproto.Header
973 var bodyPayload []byte
974 if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 {
975 headerBytes := payload[:idx]
976 rawBody := payload[idx+4:]
977
978 var contentHeaders bytes.Buffer
979 for _, line := range bytes.Split(headerBytes, []byte("\r\n")) {
980 if len(line) == 0 {
981 continue
982 }
983 parts := bytes.SplitN(line, []byte(": "), 2)
984 if len(parts) != 2 {
985 continue
986 }
987 key := string(parts[0])
988 val := string(parts[1])
989 upper := strings.ToUpper(key)
990 if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" {
991 // Keep content headers with the body for the signed part
992 contentHeaders.Write(line)
993 contentHeaders.WriteString("\r\n")
994 } else {
995 // Transport headers go to the top-level message
996 header.Set(key, val)
997 }
998 }
999
1000 // Reconstruct body payload: content headers + blank line + body
1001 contentHeaders.WriteString("\r\n")
1002 contentHeaders.Write(rawBody)
1003 bodyPayload = contentHeaders.Bytes()
1004 } else {
1005 bodyPayload = payload
1006 }
1007
1008 // Create multipart/signed message using go-pgpmail
1009 var signed bytes.Buffer
1010
1011 mw, err := pgpmail.Sign(&signed, header, entity, nil)
1012 if err != nil {
1013 return nil, fmt.Errorf("failed to create PGP signer: %w", err)
1014 }
1015
1016 // Write the body (content headers + body) to be signed
1017 if _, err := mw.Write(bodyPayload); err != nil {
1018 return nil, fmt.Errorf("failed to write message for signing: %w", err)
1019 }
1020
1021 if err := mw.Close(); err != nil {
1022 return nil, fmt.Errorf("failed to finalize PGP signature: %w", err)
1023 }
1024
1025 return signed.Bytes(), nil
1026}
1027
1028// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token.
1029func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) {
1030 // Get PIN from account (loaded from keyring)
1031 pin := account.PGPPIN
1032 if pin == "" {
1033 return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings")
1034 }
1035
1036 if account.PGPPublicKey == "" {
1037 return nil, fmt.Errorf("PGP public key path is required for YubiKey signing")
1038 }
1039
1040 // Use the pgp package to sign with YubiKey
1041 signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey)
1042 if err != nil {
1043 return nil, fmt.Errorf("YubiKey signing failed: %w", err)
1044 }
1045 return signed, nil
1046}
1047
1048// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message.
1049func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) {
1050 var entityList openpgp.EntityList
1051
1052 cfgDir, err := config.GetConfigDir()
1053 if err != nil {
1054 return nil, err
1055 }
1056 pgpDir := filepath.Join(cfgDir, "pgp")
1057
1058 // Add recipient keys
1059 for _, recipient := range recipients {
1060 // Extract email address from "Name <email>" format
1061 email := strings.TrimSpace(recipient)
1062 if strings.Contains(email, "<") {
1063 parts := strings.Split(email, "<")
1064 if len(parts) == 2 {
1065 email = strings.TrimSuffix(parts[1], ">")
1066 }
1067 }
1068
1069 // Try .asc (armored) first, then .gpg (binary)
1070 var keyData []byte
1071 keyPath := filepath.Join(pgpDir, email+".asc")
1072 keyData, err = os.ReadFile(keyPath)
1073 if err != nil {
1074 keyPath = filepath.Join(pgpDir, email+".gpg")
1075 keyData, err = os.ReadFile(keyPath)
1076 if err != nil {
1077 return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err)
1078 }
1079 }
1080
1081 // Try armored format first
1082 entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData))
1083 if err != nil {
1084 // Try binary format
1085 entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData))
1086 if err != nil {
1087 return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err)
1088 }
1089 }
1090
1091 if len(entities) > 0 {
1092 entityList = append(entityList, entities[0])
1093 }
1094 }
1095
1096 // Add sender's own key (to read in Sent folder)
1097 if account.PGPPublicKey != "" {
1098 senderKey, err := os.ReadFile(account.PGPPublicKey)
1099 if err == nil {
1100 entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey))
1101 if entities == nil {
1102 entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey))
1103 }
1104 if entities != nil && len(entities) > 0 {
1105 entityList = append(entityList, entities[0])
1106 }
1107 }
1108 }
1109
1110 if len(entityList) == 0 {
1111 return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients")
1112 }
1113
1114 // Encrypt using go-pgpmail
1115 var encrypted bytes.Buffer
1116
1117 // Create a minimal header for the encrypted content
1118 var header messagetextproto.Header
1119
1120 mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil)
1121 if err != nil {
1122 return nil, fmt.Errorf("failed to create PGP encryptor: %w", err)
1123 }
1124
1125 if _, err := mw.Write(payload); err != nil {
1126 return nil, fmt.Errorf("failed to write message for encryption: %w", err)
1127 }
1128
1129 if err := mw.Close(); err != nil {
1130 return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err)
1131 }
1132
1133 return encrypted.Bytes(), nil
1134}