1package fetcher
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto/tls"
8 "crypto/x509"
9 "encoding/base64"
10 "encoding/pem"
11 "errors"
12 "fmt"
13 "io"
14 "log"
15 "mime"
16 "mime/quotedprintable"
17 "net/textproto"
18 "os"
19 "regexp"
20 "slices"
21 "sort"
22 "strings"
23 "sync"
24 "time"
25
26 "github.com/ProtonMail/go-crypto/openpgp"
27 "github.com/emersion/go-imap/v2"
28 "github.com/emersion/go-imap/v2/imapclient"
29 "github.com/emersion/go-message/mail"
30 "github.com/emersion/go-pgpmail"
31 "github.com/floatpane/matcha/config"
32 "github.com/floatpane/matcha/internal/loglevel"
33 "go.mozilla.org/pkcs7"
34 "golang.org/x/text/encoding"
35 "golang.org/x/text/encoding/ianaindex"
36 "golang.org/x/text/encoding/unicode"
37 "golang.org/x/text/transform"
38)
39
40// debugIMAPFile holds a single shared file handle for IMAP debug logging,
41// opened once via debugIMAPOnce to avoid leaking a descriptor per connection.
42var (
43 debugIMAPFile *os.File
44 debugIMAPOnce sync.Once
45)
46
47const (
48 mimeTextPlain = "text/plain"
49 mimeTextHTML = "text/html"
50 partExtracted = "extracted"
51 // defaultArchiveMailbox is the IMAP folder name used as the archive
52 // destination for any provider that does not have a custom mapping
53 // (e.g. Gmail's "[Gmail]/All Mail").
54 defaultArchiveMailbox = "Archive"
55)
56
57func getDebugIMAPWriter() io.Writer {
58 debugIMAPOnce.Do(func() {
59 if path := os.Getenv("DEBUG_IMAP"); path != "" {
60 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec
61 if err == nil {
62 debugIMAPFile = f
63 }
64 }
65 })
66 if debugIMAPFile != nil {
67 return debugIMAPFile
68 }
69 return nil
70}
71
72// CloseDebugFiles cleans up debug file handles opened during the session.
73func CloseDebugFiles() {
74 if debugIMAPFile != nil {
75 if err := debugIMAPFile.Close(); err != nil {
76 loglevel.Debugf("IMAP file close error: %v", err)
77 }
78 debugIMAPFile = nil
79 }
80}
81
82// Attachment holds data for an email attachment.
83type Attachment struct {
84 Filename string
85 PartID string // Keep PartID to fetch on demand
86 Data []byte
87 Encoding string // Store encoding for proper decoding
88 MIMEType string // Full MIME type (e.g., image/png)
89 ContentID string // Content-ID for inline assets (e.g., cid: references)
90 Inline bool // True when the part is meant to be displayed inline
91 IsSMIMESignature bool // True if this attachment is an S/MIME signature
92 SMIMEVerified bool // True if the S/MIME signature was verified successfully
93 IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted
94 IsPGPSignature bool // True if this attachment is a PGP signature
95 PGPVerified bool // True if the PGP signature was verified successfully
96 IsPGPEncrypted bool // True if the PGP content was successfully decrypted
97 IsCalendarInvite bool // True if this attachment is a calendar invite (.ics)
98 CalendarEvent interface{} // Parsed calendar event (calendar.Event pointer)
99}
100
101type Email struct {
102 UID uint32
103 From string
104 To []string
105 ReplyTo []string
106 Subject string
107 Body string
108 BodyMIMEType string // mimeTextHTML or mimeTextPlain; empty when unknown (legacy cache rows). Lets the renderer skip markdown→HTML for already-HTML bodies.
109 Date time.Time
110 IsRead bool
111 MessageID string
112 InReplyTo string
113 References []string
114 Attachments []Attachment
115 AccountID string // ID of the account this email belongs to
116}
117
118var headerMessageIDRE = regexp.MustCompile(`<[^>]+>`)
119
120// Folder represents an IMAP mailbox/folder.
121type Folder struct {
122 Name string
123 Delimiter string
124 Unread uint32
125 Attributes []string
126}
127
128// formatAddress returns "Name <email>" when a Name is present,
129// otherwise just "email".
130func formatAddress(addr imap.Address) string {
131 email := addr.Addr()
132 if addr.Name != "" {
133 return addr.Name + " <" + email + ">"
134 }
135 return email
136}
137
138func hasSeenFlag(flags []imap.Flag) bool {
139 return slices.Contains(flags, imap.FlagSeen)
140}
141
142// normalizeGmailAddress canonicalizes a Gmail address by stripping the "+tag"
143// subaddress and removing dots from the local part. Gmail treats
144// "u.s.e.r+tag@gmail.com" and "user@gmail.com" as the same mailbox.
145func normalizeGmailAddress(addr string) string {
146 at := strings.LastIndex(addr, "@")
147 if at < 0 {
148 return addr
149 }
150 local, domain := addr[:at], addr[at:]
151 if plus := strings.Index(local, "+"); plus >= 0 {
152 local = local[:plus]
153 }
154 local = strings.ReplaceAll(local, ".", "")
155 return local + domain
156}
157
158// addressMatches reports whether candidate matches the configured fetch email.
159// For Gmail accounts, subaddressed forms ("local+tag@gmail.com") and dotted
160// forms ("l.o.c.a.l@gmail.com") also match.
161// fetchEmail must already be lowercased and trimmed.
162func addressMatches(candidate, fetchEmail string, account *config.Account) bool {
163 candidate = strings.ToLower(strings.TrimSpace(candidate))
164 if candidate == "" || fetchEmail == "" {
165 return false
166 }
167 if candidate == fetchEmail {
168 return true
169 }
170 if account != nil && strings.EqualFold(account.ServiceProvider, "gmail") {
171 return normalizeGmailAddress(candidate) == normalizeGmailAddress(fetchEmail)
172 }
173 return false
174}
175
176// deliveryHeadersMatch checks if any of the Delivered-To, X-Forwarded-To, or
177// X-Original-To headers contain the given email address. This catches
178// auto-forwarded emails where the envelope To/Cc don't match the local account.
179func deliveryHeadersMatch(data []byte, fetchEmail string, account *config.Account) bool {
180 if len(data) == 0 {
181 return false
182 }
183 // Parse as MIME headers
184 reader := textproto.NewReader(bufio.NewReader(bytes.NewReader(data)))
185 headers, err := reader.ReadMIMEHeader()
186 if err != nil && len(headers) == 0 {
187 return false
188 }
189 for _, key := range []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"} {
190 for _, val := range headers.Values(key) {
191 if addressMatches(val, fetchEmail, account) {
192 return true
193 }
194 }
195 }
196 return false
197}
198
199func headerMessageIDs(data []byte, key string) []string {
200 if len(data) == 0 {
201 return nil
202 }
203 reader := textproto.NewReader(bufio.NewReader(bytes.NewReader(data)))
204 headers, err := reader.ReadMIMEHeader()
205 if err != nil && len(headers) == 0 {
206 return nil
207 }
208 var ids []string
209 for _, value := range headers.Values(key) {
210 matches := headerMessageIDRE.FindAllString(value, -1)
211 if len(matches) == 0 {
212 for _, field := range strings.Fields(value) {
213 ids = append(ids, strings.TrimSpace(field))
214 }
215 continue
216 }
217 for _, match := range matches {
218 ids = append(ids, strings.TrimSpace(match))
219 }
220 }
221 return ids
222}
223
224func firstEnvelopeInReplyTo(values []string) string {
225 if len(values) == 0 {
226 return ""
227 }
228 return values[0]
229}
230
231func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
232 contentType := header.Get("Content-Type")
233 mediaType, params, parseErr := mime.ParseMediaType(contentType)
234
235 charset := "utf-8"
236 if parseErr != nil {
237 charset = bestEffortCharset(contentType)
238 } else if params["charset"] != "" {
239 charset = strings.ToLower(params["charset"])
240 }
241
242 decodedBody, err := decodeReaderWithCharset(reader, charset)
243 if err != nil {
244 return "", err
245 }
246
247 if parseErr == nil && strings.HasPrefix(mediaType, "multipart/") {
248 return "[This is a multipart message]", nil
249 }
250
251 return string(decodedBody), nil
252}
253
254func decodeReaderWithCharset(reader io.Reader, charset string) ([]byte, error) {
255 enc := lookupCharsetEncoding(charset)
256 transformReader := transform.NewReader(reader, enc.NewDecoder())
257 return io.ReadAll(transformReader)
258}
259
260// lookupCharsetEncoding resolves a charset name, falling back to UTF-8.
261func lookupCharsetEncoding(charset string) encoding.Encoding {
262 if enc, err := ianaindex.IANA.Encoding(charset); err == nil && enc != nil {
263 return enc
264 }
265 if enc, err := ianaindex.IANA.Encoding("utf-8"); err == nil && enc != nil {
266 return enc
267 }
268 return unicode.UTF8
269}
270
271func bestEffortCharset(contentType string) string {
272 for _, param := range strings.Split(contentType, ";") {
273 key, value, found := strings.Cut(param, "=")
274 if !found || !strings.EqualFold(strings.TrimSpace(key), "charset") {
275 continue
276 }
277
278 value = strings.Trim(strings.TrimSpace(value), `"`)
279 if value != "" {
280 return strings.ToLower(value)
281 }
282 }
283
284 return "utf-8"
285}
286
287func decodeHeader(header string) string {
288 dec := new(mime.WordDecoder)
289 dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
290 enc, err := ianaindex.IANA.Encoding(charset)
291 if err != nil {
292 return nil, err
293 }
294 if enc == nil {
295 return nil, fmt.Errorf("fetcher: no encoding implementation for charset %q", charset)
296 }
297 return transform.NewReader(input, enc.NewDecoder()), nil
298 }
299 decoded, err := dec.DecodeHeader(header)
300 if err != nil {
301 return header
302 }
303 return decoded
304}
305
306func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) {
307 switch strings.ToLower(encoding) {
308 case "base64":
309 decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes))
310 data, err := io.ReadAll(decoder)
311 if err != nil {
312 return nil, err
313 }
314 return data, nil
315 case "quoted-printable":
316 data, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes)))
317 if err != nil {
318 return nil, err
319 }
320 return data, nil
321 default:
322 return rawBytes, nil
323 }
324}
325
326// parsePartID converts a string part ID like "1.2.3" to []int{1, 2, 3}.
327// Special cases: "TEXT" maps to empty with PartSpecifierText (handled by caller).
328func parsePartID(partID string) []int {
329 if partID == "" || partID == "TEXT" {
330 return nil
331 }
332 var parts []int
333 for _, s := range strings.Split(partID, ".") {
334 n := 0
335 for _, c := range s {
336 if c >= '0' && c <= '9' {
337 n = n*10 + int(c-'0')
338 }
339 }
340 parts = append(parts, n)
341 }
342 return parts
343}
344
345// formatPartPath converts a Walk path like []int{1, 2, 3} to "1.2.3".
346func formatPartPath(path []int) string {
347 if len(path) == 0 {
348 return ""
349 }
350 parts := make([]string, len(path))
351 for i, p := range path {
352 parts[i] = fmt.Sprintf("%d", p)
353 }
354 return strings.Join(parts, ".")
355}
356
357// getBodyStructureBoundary extracts the boundary parameter from a multipart body structure.
358func getBodyStructureBoundary(bs imap.BodyStructure) string {
359 if mp, ok := bs.(*imap.BodyStructureMultiPart); ok {
360 if mp.Extended != nil && mp.Extended.Params != nil {
361 return mp.Extended.Params["boundary"]
362 }
363 }
364 return ""
365}
366
367// uidsToUIDSet converts a slice of uint32 UIDs to an imap.UIDSet.
368func uidsToUIDSet(uids []uint32) imap.UIDSet {
369 var uidSet imap.UIDSet
370 for _, uid := range uids {
371 uidSet.AddNum(imap.UID(uid))
372 }
373 return uidSet
374}
375
376func connectWithHandler(account *config.Account, handler *imapclient.UnilateralDataHandler) (*imapclient.Client, error) {
377 return connectWithOptions(account, &imapclient.Options{
378 UnilateralDataHandler: handler,
379 })
380}
381
382func connect(account *config.Account) (*imapclient.Client, error) {
383 return connectWithOptions(account, nil)
384}
385
386func connectWithOptions(account *config.Account, extraOpts *imapclient.Options) (*imapclient.Client, error) {
387 imapServer := account.GetIMAPServer()
388 imapPort := account.GetIMAPPort()
389
390 if imapServer == "" {
391 return nil, fmt.Errorf("unsupported service_provider: %s", account.ServiceProvider)
392 }
393
394 addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
395
396 options := &imapclient.Options{
397 TLSConfig: &tls.Config{
398 ServerName: imapServer,
399 InsecureSkipVerify: account.Insecure, //nolint:gosec
400 MinVersion: tls.VersionTLS12,
401 ClientSessionCache: account.GetClientSessionCache(),
402 VerifyConnection: func(cs tls.ConnectionState) error {
403 loglevel.Debugf("IMAP TLS connection resumed: %t", cs.DidResume)
404 return nil
405 },
406 },
407 }
408 if extraOpts != nil {
409 options.UnilateralDataHandler = extraOpts.UnilateralDataHandler
410 options.DebugWriter = extraOpts.DebugWriter
411 }
412 if w := getDebugIMAPWriter(); w != nil {
413 options.DebugWriter = w
414 }
415
416 var c *imapclient.Client
417 var err error
418
419 // If using standard non-implicit ports (1143 or 143), use DialStartTLS
420 if imapPort == 1143 || imapPort == 143 {
421 c, err = imapclient.DialStartTLS(addr, options)
422 if err != nil {
423 return nil, err
424 }
425 } else {
426 // Otherwise default to implicit TLS (port 993)
427 c, err = imapclient.DialTLS(addr, options)
428 if err != nil {
429 return nil, err
430 }
431 }
432
433 if err := c.WaitGreeting(); err != nil {
434 c.Close() //nolint:errcheck,gosec
435 return nil, err
436 }
437
438 // Authenticate using OAuth2 (XOAUTH2) or plain password
439 if account.IsOAuth2() {
440 token, err := config.GetOAuth2Token(account.Email)
441 if err != nil {
442 return nil, fmt.Errorf("oauth2: %w", err)
443 }
444 if err := c.Authenticate(newXOAuth2Client(account.Email, token)); err != nil {
445 return nil, fmt.Errorf("XOAUTH2 authentication failed: %w", err)
446 }
447 } else {
448 if err := c.Login(account.Email, account.Password).Wait(); err != nil {
449 return nil, fmt.Errorf("authentication error: %w", err)
450 }
451 }
452
453 return c, nil
454}
455
456func getSentMailbox(account *config.Account) string {
457 switch account.ServiceProvider {
458 case config.ProviderGmail:
459 return "[Gmail]/Sent Mail"
460 case "outlook":
461 return "Sent Items"
462 case "icloud":
463 return "Sent Messages"
464 default:
465 return "Sent"
466 }
467}
468
469// getMailboxByAttr finds a mailbox with the given IMAP attribute (e.g., \All, \Sent, \Trash).
470func getMailboxByAttr(c *imapclient.Client, attr imap.MailboxAttr) (string, error) {
471 listCmd := c.List("", "*", nil)
472 defer listCmd.Close() //nolint:errcheck
473
474 var foundMailbox string
475 for {
476 data := listCmd.Next()
477 if data == nil {
478 break
479 }
480 for _, a := range data.Attrs {
481 if a == attr {
482 foundMailbox = data.Mailbox
483 break
484 }
485 }
486 }
487
488 if err := listCmd.Close(); err != nil {
489 return "", err
490 }
491
492 if foundMailbox == "" {
493 return "", fmt.Errorf("no mailbox found with attribute %s", attr)
494 }
495
496 return foundMailbox, nil
497}
498
499func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) {
500 if hasBackendProvider(account) {
501 p, err := newBackendProvider(account)
502 if err != nil {
503 return nil, err
504 }
505 defer p.Close() //nolint:errcheck
506 emails, err := p.FetchEmails(context.Background(), mailbox, limit, offset)
507 if err != nil {
508 return nil, err
509 }
510 return backendEmailsToFetcher(emails), nil
511 }
512
513 c, err := connect(account)
514 if err != nil {
515 return nil, err
516 }
517 defer c.Close() //nolint:errcheck
518
519 selectData, err := c.Select(mailbox, nil).Wait()
520 if err != nil {
521 return nil, err
522 }
523
524 if selectData.NumMessages == 0 {
525 return []Email{}, nil
526 }
527
528 var allEmails []Email
529
530 // Start from the top minus offset
531 if selectData.NumMessages <= offset {
532 return []Email{}, nil
533 }
534 cursor := selectData.NumMessages - offset
535
536 // Determine if we should filter
537 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
538 if fetchEmail == "" {
539 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
540 }
541 isSentMailbox := mailbox == getSentMailbox(account)
542
543 // Delivery header section for matching auto-forwarded emails
544 deliveryHeaderSection := &imap.FetchItemBodySection{
545 Specifier: imap.PartSpecifierHeader,
546 HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To", "References"},
547 Peek: true,
548 }
549
550 // Loop until we have enough emails or run out of messages
551 for len(allEmails) < int(limit) && cursor > 0 {
552 chunkSize := limit
553
554 from := uint32(1)
555 if cursor > chunkSize {
556 from = cursor - chunkSize + 1
557 }
558
559 var seqset imap.SeqSet
560 seqset.AddRange(from, cursor)
561
562 fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
563 Envelope: true,
564 UID: true,
565 Flags: true,
566 BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
567 })
568
569 batchMsgs, err := fetchCmd.Collect()
570 if err != nil {
571 return nil, err
572 }
573
574 // Filter messages in this batch
575 var batchEmails []Email
576 for _, msg := range batchMsgs {
577 if msg.Envelope == nil {
578 continue
579 }
580
581 var fromAddr string
582 if len(msg.Envelope.From) > 0 {
583 fromAddr = formatAddress(msg.Envelope.From[0])
584 }
585
586 var toAddrList []string
587 for _, addr := range msg.Envelope.To {
588 toAddrList = append(toAddrList, addr.Addr())
589 }
590 for _, addr := range msg.Envelope.Cc {
591 toAddrList = append(toAddrList, addr.Addr())
592 }
593
594 var replyToAddrList []string
595 for _, addr := range msg.Envelope.ReplyTo {
596 replyToAddrList = append(replyToAddrList, addr.Addr())
597 }
598
599 matched := false
600 switch {
601 case account.CatchAll:
602 matched = true
603 case isSentMailbox:
604 var senderEmail string
605 if len(msg.Envelope.From) > 0 {
606 senderEmail = msg.Envelope.From[0].Addr()
607 }
608 if addressMatches(senderEmail, fetchEmail, account) {
609 matched = true
610 }
611 default:
612 for _, r := range toAddrList {
613 if addressMatches(r, fetchEmail, account) {
614 matched = true
615 break
616 }
617 }
618 // Check delivery headers for auto-forwarded emails
619 if !matched {
620 headerData := msg.FindBodySection(deliveryHeaderSection)
621 matched = deliveryHeadersMatch(headerData, fetchEmail, account)
622 }
623 }
624
625 if !matched {
626 continue
627 }
628
629 headerData := msg.FindBodySection(deliveryHeaderSection)
630 batchEmails = append(batchEmails, Email{
631 UID: uint32(msg.UID),
632 From: fromAddr,
633 To: toAddrList,
634 ReplyTo: replyToAddrList,
635 Subject: decodeHeader(msg.Envelope.Subject),
636 Date: msg.Envelope.Date,
637 IsRead: hasSeenFlag(msg.Flags),
638 MessageID: msg.Envelope.MessageID,
639 InReplyTo: firstEnvelopeInReplyTo(msg.Envelope.InReplyTo),
640 References: headerMessageIDs(headerData, "References"),
641 AccountID: account.ID,
642 })
643 }
644
645 // Sort batch Newest -> Oldest by UID desc
646 sort.Slice(batchEmails, func(i, j int) bool {
647 return batchEmails[i].UID > batchEmails[j].UID
648 })
649
650 allEmails = append(allEmails, batchEmails...)
651 cursor = from - 1
652 }
653
654 // Trim if we have too many
655 if len(allEmails) > int(limit) {
656 allEmails = allEmails[:limit]
657 }
658
659 return allEmails, nil
660}
661
662// FetchEmailBodyFromMailbox returns the chosen body, its MIME type
663// (mimeTextHTML or mimeTextPlain; empty if it could not be resolved), the
664// parsed attachments, and any error. The MIME type lets the renderer
665// skip the markdown→HTML pre-pass for already-HTML bodies.
666func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, string, []Attachment, error) { //nolint:gocyclo
667 if hasBackendProvider(account) {
668 p, err := newBackendProvider(account)
669 if err != nil {
670 return "", "", nil, err
671 }
672 defer p.Close() //nolint:errcheck
673 body, mimeType, atts, err := p.FetchEmailBody(context.Background(), mailbox, uid)
674 if err != nil {
675 return "", "", nil, err
676 }
677 return body, mimeType, backendAttachmentsToFetcher(atts), nil
678 }
679
680 c, err := connect(account)
681 if err != nil {
682 return "", "", nil, err
683 }
684 defer c.Close() //nolint:errcheck
685
686 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
687 return "", "", nil, err
688 }
689
690 uidSet := imap.UIDSetNum(imap.UID(uid))
691
692 fetchWholeMessage := func() ([]byte, error) {
693 wholeSection := &imap.FetchItemBodySection{Peek: true}
694 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
695 BodySection: []*imap.FetchItemBodySection{wholeSection},
696 })
697 msgs, err := fetchCmd.Collect()
698 if err != nil {
699 return nil, err
700 }
701 if len(msgs) > 0 {
702 if data := msgs[0].FindBodySection(wholeSection); data != nil {
703 return data, nil
704 }
705 }
706 return nil, fmt.Errorf("could not fetch whole message")
707 }
708
709 fetchInlinePart := func(partID, encoding string) ([]byte, error) {
710 part := parsePartID(partID)
711 section := &imap.FetchItemBodySection{
712 Part: part,
713 Peek: true,
714 }
715
716 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
717 BodySection: []*imap.FetchItemBodySection{section},
718 })
719 msgs, err := fetchCmd.Collect()
720 if err != nil {
721 return nil, err
722 }
723
724 if len(msgs) == 0 {
725 return nil, fmt.Errorf("could not fetch inline part %s", partID)
726 }
727
728 rawBytes := msgs[0].FindBodySection(section)
729 if rawBytes == nil {
730 return nil, fmt.Errorf("could not get inline part body %s", partID)
731 }
732
733 return decodeAttachmentData(rawBytes, encoding)
734 }
735
736 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
737 BodyStructure: &imap.FetchItemBodyStructure{Extended: true},
738 })
739 bsMsgs, err := fetchCmd.Collect()
740 if err != nil {
741 return "", "", nil, err
742 }
743
744 if len(bsMsgs) == 0 || bsMsgs[0].BodyStructure == nil {
745 return "", "", nil, fmt.Errorf("no message or body structure found with UID %d", uid)
746 }
747
748 msg := bsMsgs[0]
749
750 var plainPartID, plainPartEncoding string
751 var htmlPartID, htmlPartEncoding string
752 var attachments []Attachment
753 var extractedBody string // Used if we intercept and decrypt a payload
754 // MIME type of extractedBody. Set alongside every assignment to extractedBody
755 // so the renderer can skip the markdown→HTML pre-pass for HTML payloads while
756 // still letting markdown error messages render formatted.
757 var extractedBodyMIMEType string
758
759 var checkPart func(part *imap.BodyStructureSinglePart, partID string) //nolint:staticcheck
760 checkPart = func(part *imap.BodyStructureSinglePart, partID string) {
761 // Check for text content (prefer html over plain)
762 if strings.EqualFold(part.Type, "text") {
763 sub := strings.ToLower(part.Subtype)
764 switch sub {
765 case "html":
766 if htmlPartID == "" {
767 htmlPartID = partID
768 htmlPartEncoding = part.Encoding
769 }
770 case "plain":
771 if plainPartID == "" {
772 plainPartID = partID
773 plainPartEncoding = part.Encoding
774 }
775 }
776 }
777
778 // Check for attachments using multiple methods
779 filename := part.Filename()
780 // Fallback: check Params (for name parameter)
781 if filename == "" {
782 if fn, ok := part.Params["name"]; ok && fn != "" {
783 filename = fn
784 }
785 }
786 // Fallback: check Params for filename
787 if filename == "" {
788 if fn, ok := part.Params["filename"]; ok && fn != "" {
789 filename = fn
790 }
791 }
792
793 // Add as attachment if it has a disposition or a filename (and not just plain text).
794 // Allow inline parts without filenames (common for cid images).
795 contentID := strings.Trim(part.ID, "<>")
796 mimeType := part.MediaType()
797 dispValue := ""
798 dispParams := map[string]string{}
799 if part.Disposition() != nil {
800 dispValue = part.Disposition().Value
801 dispParams = part.Disposition().Params
802 }
803 _ = dispParams // used below in attachment fallback checks
804 isCID := contentID != ""
805 isInline := strings.EqualFold(dispValue, "inline") || isCID
806
807 if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") {
808 filename = "inline"
809 }
810
811 // === S/MIME ENCRYPTION AND OPAQUE VERIFICATION ===
812 if filename == "smime.p7m" || mimeType == "application/pkcs7-mime" {
813 data, err := fetchInlinePart(partID, part.Encoding)
814 if err != nil && partID == "1" {
815 // Fallback for single-part messages where PEEK[1] fails
816 data, err = fetchInlinePart("TEXT", part.Encoding)
817 }
818
819 if err != nil {
820 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err)
821 extractedBodyMIMEType = mimeTextPlain
822 htmlPartID = partExtracted
823 } else {
824 p7, parseErr := pkcs7.Parse(data)
825 if parseErr != nil {
826 // Fallback: IMAP servers sometimes drop the transfer-encoding header.
827 // We manually strip newlines and attempt a base64 decode just in case.
828 cleanData := bytes.ReplaceAll(data, []byte("\n"), []byte(""))
829 cleanData = bytes.ReplaceAll(cleanData, []byte("\r"), []byte(""))
830 if decoded, b64err := base64.StdEncoding.DecodeString(string(cleanData)); b64err == nil {
831 p7, parseErr = pkcs7.Parse(decoded)
832 }
833 }
834
835 if parseErr != nil {
836 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr)
837 extractedBodyMIMEType = mimeTextPlain
838 htmlPartID = partExtracted
839 } else {
840 var innerBytes []byte
841 isEncrypted, isOpaqueSigned, smimeTrusted := false, false, false
842 decryptionErr := ""
843
844 // 1. Try to Decrypt
845 if account.SMIMECert != "" && account.SMIMEKey != "" {
846 cData, err1 := os.ReadFile(account.SMIMECert)
847 kData, err2 := os.ReadFile(account.SMIMEKey)
848 if err1 != nil || err2 != nil {
849 decryptionErr = fmt.Sprintf("Failed to read cert/key files. Cert: %v, Key: %v", err1, err2)
850 } else {
851 cBlock, _ := pem.Decode(cData)
852 kBlock, _ := pem.Decode(kData)
853 if cBlock == nil || kBlock == nil {
854 decryptionErr = "Failed to decode PEM blocks from cert/key files."
855 } else {
856 cert, err3 := x509.ParseCertificate(cBlock.Bytes)
857 var privKey any
858 var err4 error
859 if key, err := x509.ParsePKCS8PrivateKey(kBlock.Bytes); err == nil {
860 privKey = key
861 } else if key, err := x509.ParsePKCS1PrivateKey(kBlock.Bytes); err == nil {
862 privKey = key
863 } else if key, err := x509.ParseECPrivateKey(kBlock.Bytes); err == nil {
864 privKey = key
865 } else {
866 err4 = errors.New("unsupported private key format")
867 }
868
869 if err3 != nil || err4 != nil {
870 decryptionErr = fmt.Sprintf("Failed to parse cert/key. Cert: %v, Key: %v", err3, err4)
871 } else {
872 dec, err := p7.Decrypt(cert, privKey)
873 if err == nil {
874 innerBytes = dec
875 isEncrypted = true
876 } else {
877 decryptionErr = fmt.Sprintf("PKCS7 Decrypt failed: %v", err)
878 }
879 }
880 }
881 }
882 } else {
883 // Only set error if it actually is enveloped data (encrypted)
884 // If it's just opaque signed, we shouldn't error out.
885 decryptionErr = "S/MIME Cert or Key path is missing in settings."
886 }
887
888 // 2. If not encrypted, check if it's an opaque signature
889 if !isEncrypted && len(p7.Signers) > 0 {
890 isOpaqueSigned = true
891 innerBytes = p7.Content
892 decryptionErr = "" // Clear encryption error because it wasn't encrypted to begin with
893 roots, _ := x509.SystemCertPool()
894 if roots == nil {
895 roots = x509.NewCertPool()
896 }
897 if err := p7.VerifyWithChain(roots); err == nil {
898 smimeTrusted = true
899 }
900 }
901
902 // 3. Parse Inner MIME payload
903 if len(innerBytes) > 0 {
904 mr, err := mail.CreateReader(bytes.NewReader(innerBytes))
905 if err == nil {
906 for {
907 p, err := mr.NextPart()
908 if err != nil {
909 break
910 }
911 cType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
912 disp, dParams, _ := mime.ParseMediaType(p.Header.Get("Content-Disposition"))
913 b, readErr := io.ReadAll(p.Body) // Auto-decodes quoted-printable/base64
914 if readErr != nil {
915 log.Printf("fetcher: reading inner MIME part body: %v", readErr)
916 continue
917 }
918
919 if disp == "attachment" || disp == "inline" || (!strings.HasPrefix(cType, "multipart/") && cType != mimeTextPlain && cType != mimeTextHTML) {
920 fn := dParams["filename"]
921 if fn == "" {
922 _, cp, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
923 fn = cp["name"]
924 }
925 attachments = append(attachments, Attachment{
926 Filename: fn, Data: b, MIMEType: cType, Inline: disp == "inline",
927 })
928 } else {
929 if cType == mimeTextHTML {
930 extractedBody = string(b)
931 extractedBodyMIMEType = mimeTextHTML
932 htmlPartID = partExtracted // Skip IMAP fetch
933 } else if cType == mimeTextPlain && extractedBody == "" {
934 extractedBody = string(b)
935 extractedBodyMIMEType = mimeTextPlain
936 plainPartID = partExtracted
937 }
938 }
939 }
940 } else {
941 extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes))
942 extractedBodyMIMEType = mimeTextPlain
943 htmlPartID = partExtracted
944 }
945
946 attachments = append(attachments, Attachment{
947 Filename: "smime-status.internal",
948 IsSMIMESignature: isOpaqueSigned,
949 SMIMEVerified: smimeTrusted,
950 IsSMIMEEncrypted: isEncrypted,
951 })
952 return // Stop checking IMAP structure, we hijacked it
953 }
954 extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr)
955 extractedBodyMIMEType = mimeTextPlain
956 htmlPartID = partExtracted
957 }
958 }
959 }
960
961 // === S/MIME DETACHED SIGNATURE VERIFICATION ===
962 if filename == "smime.p7s" || mimeType == "application/pkcs7-signature" {
963 att := Attachment{
964 Filename: filename,
965 PartID: partID,
966 Encoding: part.Encoding,
967 MIMEType: mimeType,
968 ContentID: contentID,
969 Inline: isInline,
970 IsSMIMESignature: true,
971 }
972 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
973 att.Data = data
974 p7, err := pkcs7.Parse(data)
975 if err == nil {
976 boundary := getBodyStructureBoundary(msg.BodyStructure)
977 if boundary != "" {
978 rawEmail, err := fetchWholeMessage()
979 if err == nil {
980 fullBoundary := []byte("--" + boundary)
981 firstIdx := bytes.Index(rawEmail, fullBoundary)
982 if firstIdx != -1 {
983 startIdx := firstIdx + len(fullBoundary)
984 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
985 startIdx++
986 }
987 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
988 startIdx++
989 }
990 secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
991 if secondIdx != -1 {
992 endIdx := startIdx + secondIdx
993 if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
994 endIdx--
995 }
996 if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
997 endIdx--
998 }
999 signedData := rawEmail[startIdx:endIdx]
1000 canonical := bytes.ReplaceAll(signedData, []byte("\r\n"), []byte("\n"))
1001 canonical = bytes.ReplaceAll(canonical, []byte("\n"), []byte("\r\n"))
1002
1003 roots, _ := x509.SystemCertPool()
1004 if roots == nil {
1005 roots = x509.NewCertPool()
1006 }
1007
1008 p7.Content = canonical
1009 if err := p7.VerifyWithChain(roots); err == nil {
1010 att.SMIMEVerified = true
1011 } else {
1012 p7.Content = append(canonical, '\r', '\n') //nolint:gocritic
1013 if err := p7.VerifyWithChain(roots); err == nil {
1014 att.SMIMEVerified = true
1015 } else {
1016 p7.Content = bytes.TrimRight(canonical, "\r\n")
1017 if err := p7.VerifyWithChain(roots); err == nil {
1018 att.SMIMEVerified = true
1019 }
1020 }
1021 }
1022 }
1023 }
1024 }
1025 }
1026 }
1027 }
1028 attachments = append(attachments, att)
1029 }
1030
1031 // === PGP ENCRYPTED MESSAGE DETECTION ===
1032 // PGP encrypted messages have two parts: version info and encrypted data.
1033 // We handle decryption when we find the encrypted data part (application/octet-stream).
1034 // Skip the version info part (application/pgp-encrypted) and continue processing.
1035
1036 // Detect encrypted data part of PGP message
1037 if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") {
1038 // This might be PGP encrypted data
1039 data, err := fetchInlinePart(partID, part.Encoding)
1040 if err == nil && bytes.Contains(data, []byte("-----BEGIN PGP MESSAGE-----")) {
1041 // This is PGP encrypted content
1042 if account.PGPPrivateKey != "" {
1043 decrypted, err := decryptPGPMessage(data, account)
1044 if err == nil {
1045 // Parse the decrypted MIME content
1046 mr, err := mail.CreateReader(bytes.NewReader(decrypted))
1047 if err == nil {
1048 for {
1049 p, err := mr.NextPart()
1050 if errors.Is(err, io.EOF) {
1051 break
1052 }
1053 if err != nil {
1054 break
1055 }
1056
1057 if h, ok := p.Header.(*mail.InlineHeader); ok {
1058 ct, _, _ := h.ContentType()
1059 if strings.HasPrefix(ct, mimeTextHTML) {
1060 body, _ := io.ReadAll(p.Body)
1061 extractedBody = string(body)
1062 extractedBodyMIMEType = mimeTextHTML
1063 htmlPartID = "decrypted"
1064 } else if strings.HasPrefix(ct, mimeTextPlain) && extractedBody == "" {
1065 body, _ := io.ReadAll(p.Body)
1066 extractedBody = string(body)
1067 extractedBodyMIMEType = mimeTextPlain
1068 plainPartID = "decrypted"
1069 }
1070 }
1071 }
1072
1073 // Add status marker
1074 attachments = append(attachments, Attachment{
1075 Filename: "pgp-status.internal",
1076 IsPGPEncrypted: true,
1077 PGPVerified: true, // Decryption succeeded
1078 })
1079 }
1080 } else {
1081 extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err)
1082 extractedBodyMIMEType = mimeTextPlain
1083 htmlPartID = partExtracted
1084 }
1085 } else {
1086 extractedBody = "**PGP Encrypted:** Private key not configured\n"
1087 extractedBodyMIMEType = mimeTextPlain
1088 htmlPartID = partExtracted
1089 }
1090 }
1091 }
1092
1093 // === PGP DETACHED SIGNATURE VERIFICATION ===
1094 if filename == "signature.asc" || mimeType == "application/pgp-signature" { //nolint:gocritic
1095 att := Attachment{
1096 Filename: filename,
1097 PartID: partID,
1098 Encoding: part.Encoding,
1099 MIMEType: mimeType,
1100 ContentID: contentID,
1101 Inline: isInline,
1102 IsPGPSignature: true,
1103 }
1104
1105 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
1106 att.Data = data
1107
1108 // Try to verify the signature
1109 boundary := getBodyStructureBoundary(msg.BodyStructure)
1110 if boundary != "" {
1111 rawEmail, err := fetchWholeMessage()
1112 if err == nil {
1113 // Extract signed content (similar to S/MIME)
1114 fullBoundary := []byte("--" + boundary)
1115 firstIdx := bytes.Index(rawEmail, fullBoundary)
1116 if firstIdx != -1 {
1117 startIdx := firstIdx + len(fullBoundary)
1118 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' {
1119 startIdx++
1120 }
1121 if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' {
1122 startIdx++
1123 }
1124 secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary)
1125 if secondIdx != -1 {
1126 endIdx := startIdx + secondIdx
1127 if endIdx > 0 && rawEmail[endIdx-1] == '\n' {
1128 endIdx--
1129 }
1130 if endIdx > 0 && rawEmail[endIdx-1] == '\r' {
1131 endIdx--
1132 }
1133 signedData := rawEmail[startIdx:endIdx]
1134
1135 // Verify PGP signature
1136 verified := verifyPGPSignature(signedData, data, account)
1137 att.PGPVerified = verified
1138 }
1139 }
1140 }
1141 }
1142 }
1143 attachments = append(attachments, att)
1144 } else if mimeType == "text/calendar" || strings.HasSuffix(strings.ToLower(filename), ".ics") {
1145 // === CALENDAR INVITE DETECTION ===
1146 att := Attachment{
1147 Filename: filename,
1148 PartID: partID,
1149 Encoding: part.Encoding,
1150 MIMEType: mimeType,
1151 IsCalendarInvite: true,
1152 }
1153
1154 // Fetch and parse calendar data
1155 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
1156 att.Data = data
1157 // Parse will be done lazily in calendar package when needed
1158 }
1159 attachments = append(attachments, att)
1160 } else if (filename != "" || isCID) && (strings.EqualFold(dispValue, "attachment") || isInline || !strings.EqualFold(part.Type, "text")) {
1161 att := Attachment{
1162 Filename: filename,
1163 PartID: partID,
1164 Encoding: part.Encoding, // Store encoding for proper decoding
1165 MIMEType: mimeType,
1166 ContentID: contentID,
1167 Inline: isInline,
1168 }
1169 if att.Inline && strings.HasPrefix(att.MIMEType, "image/") {
1170 if data, err := fetchInlinePart(partID, part.Encoding); err == nil {
1171 att.Data = data
1172 }
1173 }
1174 attachments = append(attachments, att)
1175 }
1176 }
1177
1178 // Walk the body structure tree
1179 msg.BodyStructure.Walk(func(path []int, part imap.BodyStructure) bool {
1180 if sp, ok := part.(*imap.BodyStructureSinglePart); ok {
1181 partID := formatPartPath(path)
1182 checkPart(sp, partID)
1183 }
1184 return true
1185 })
1186
1187 // If we hijacked and decrypted the body, return it immediately
1188 if extractedBody != "" {
1189 return extractedBody, extractedBodyMIMEType, attachments, nil
1190 }
1191
1192 var body string
1193 var bodyMIMEType string
1194 textPartID := ""
1195 textPartEncoding := ""
1196 if htmlPartID != "" {
1197 textPartID = htmlPartID
1198 textPartEncoding = htmlPartEncoding
1199 bodyMIMEType = mimeTextHTML
1200 } else if plainPartID != "" {
1201 textPartID = plainPartID
1202 textPartEncoding = plainPartEncoding
1203 bodyMIMEType = mimeTextPlain
1204 }
1205 if os.Getenv("DEBUG_KITTY_IMAGES") != "" {
1206 msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID)
1207 log.Print(msg)
1208 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
1209 // Use a closure with defer so a panic between open and
1210 // WriteString doesn't leak the file descriptor (#894).
1211 func() {
1212 f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec
1213 if err != nil {
1214 return
1215 }
1216 defer f.Close() //nolint:errcheck
1217 _, _ = f.WriteString(msg)
1218 }()
1219 }
1220 }
1221 if textPartID != "" {
1222 part := parsePartID(textPartID)
1223 section := &imap.FetchItemBodySection{
1224 Part: part,
1225 Peek: true,
1226 }
1227
1228 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1229 BodySection: []*imap.FetchItemBodySection{section},
1230 })
1231 msgs, err := fetchCmd.Collect()
1232 if err != nil {
1233 return "", "", nil, err
1234 }
1235
1236 if len(msgs) > 0 {
1237 if buf := msgs[0].FindBodySection(section); buf != nil {
1238 // Use the encoding from BodyStructure to decode
1239 if decoded, err := decodeAttachmentData(buf, textPartEncoding); err == nil {
1240 body = string(decoded)
1241 } else {
1242 body = string(buf)
1243 }
1244 }
1245 }
1246 }
1247
1248 return body, bodyMIMEType, attachments, nil
1249}
1250
1251func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) {
1252 if hasBackendProvider(account) {
1253 p, err := newBackendProvider(account)
1254 if err != nil {
1255 return nil, err
1256 }
1257 defer p.Close() //nolint:errcheck
1258 return p.FetchAttachment(context.Background(), mailbox, uid, partID, encoding)
1259 }
1260
1261 c, err := connect(account)
1262 if err != nil {
1263 return nil, err
1264 }
1265 defer c.Close() //nolint:errcheck
1266
1267 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1268 return nil, err
1269 }
1270
1271 uidSet := imap.UIDSetNum(imap.UID(uid))
1272 part := parsePartID(partID)
1273 section := &imap.FetchItemBodySection{
1274 Part: part,
1275 Peek: true,
1276 }
1277
1278 fetchCmd := c.Fetch(uidSet, &imap.FetchOptions{
1279 BodySection: []*imap.FetchItemBodySection{section},
1280 })
1281 msgs, err := fetchCmd.Collect()
1282 if err != nil {
1283 return nil, err
1284 }
1285
1286 if len(msgs) == 0 {
1287 return nil, fmt.Errorf("could not fetch attachment")
1288 }
1289
1290 rawBytes := msgs[0].FindBodySection(section)
1291 if rawBytes == nil {
1292 return nil, fmt.Errorf("could not get attachment body")
1293 }
1294
1295 decoded, err := decodeAttachmentData(rawBytes, encoding)
1296 if err != nil {
1297 return rawBytes, nil
1298 }
1299 return decoded, nil
1300}
1301
1302func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error {
1303 if hasBackendProvider(account) {
1304 p, err := newBackendProvider(account)
1305 if err != nil {
1306 return err
1307 }
1308 defer p.Close() //nolint:errcheck
1309 return p.MoveEmail(context.Background(), uid, sourceMailbox, destMailbox)
1310 }
1311
1312 c, err := connect(account)
1313 if err != nil {
1314 return err
1315 }
1316 defer c.Close() //nolint:errcheck
1317
1318 if _, err := c.Select(sourceMailbox, nil).Wait(); err != nil {
1319 return err
1320 }
1321
1322 uidSet := imap.UIDSetNum(imap.UID(uid))
1323 _, err = c.Move(uidSet, destMailbox).Wait()
1324 return err
1325}
1326
1327func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1328 if hasBackendProvider(account) {
1329 p, err := newBackendProvider(account)
1330 if err != nil {
1331 return err
1332 }
1333 defer p.Close() //nolint:errcheck
1334 return p.MarkAsRead(context.Background(), mailbox, uid)
1335 }
1336
1337 c, err := connect(account)
1338 if err != nil {
1339 return err
1340 }
1341 defer c.Close() //nolint:errcheck
1342
1343 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1344 return err
1345 }
1346
1347 uidSet := imap.UIDSetNum(imap.UID(uid))
1348 return c.Store(uidSet, &imap.StoreFlags{
1349 Op: imap.StoreFlagsAdd,
1350 Silent: true,
1351 Flags: []imap.Flag{imap.FlagSeen},
1352 }, nil).Close()
1353}
1354
1355func MarkEmailAsUnreadInMailbox(account *config.Account, mailbox string, uid uint32) error {
1356 if hasBackendProvider(account) {
1357 p, err := newBackendProvider(account)
1358 if err != nil {
1359 return err
1360 }
1361 defer p.Close() //nolint:errcheck
1362 return p.MarkAsUnread(context.Background(), mailbox, uid)
1363 }
1364
1365 c, err := connect(account)
1366 if err != nil {
1367 return err
1368 }
1369 defer c.Close() //nolint:errcheck
1370
1371 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1372 return err
1373 }
1374
1375 uidSet := imap.UIDSetNum(imap.UID(uid))
1376 return c.Store(uidSet, &imap.StoreFlags{
1377 Op: imap.StoreFlagsDel,
1378 Silent: true,
1379 Flags: []imap.Flag{imap.FlagSeen},
1380 }, nil).Close()
1381}
1382
1383func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1384 if hasBackendProvider(account) {
1385 p, err := newBackendProvider(account)
1386 if err != nil {
1387 return err
1388 }
1389 defer p.Close() //nolint:errcheck
1390 return p.DeleteEmail(context.Background(), mailbox, uid)
1391 }
1392
1393 c, err := connect(account)
1394 if err != nil {
1395 return err
1396 }
1397 defer c.Close() //nolint:errcheck
1398
1399 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1400 return err
1401 }
1402
1403 uidSet := imap.UIDSetNum(imap.UID(uid))
1404 if err := c.Store(uidSet, &imap.StoreFlags{
1405 Op: imap.StoreFlagsAdd,
1406 Silent: true,
1407 Flags: []imap.Flag{imap.FlagDeleted},
1408 }, nil).Close(); err != nil {
1409 return err
1410 }
1411
1412 return c.Expunge().Close()
1413}
1414
1415func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
1416 if hasBackendProvider(account) {
1417 p, err := newBackendProvider(account)
1418 if err != nil {
1419 return err
1420 }
1421 defer p.Close() //nolint:errcheck
1422 return p.ArchiveEmail(context.Background(), mailbox, uid)
1423 }
1424
1425 c, err := connect(account)
1426 if err != nil {
1427 return err
1428 }
1429 defer c.Close() //nolint:errcheck
1430
1431 var archiveMailbox string
1432 switch account.ServiceProvider {
1433 case config.ProviderGmail:
1434 // For Gmail, find the mailbox with the \All attribute
1435 archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1436 if err != nil {
1437 // Fallback to hardcoded path if attribute lookup fails
1438 archiveMailbox = "[Gmail]/All Mail"
1439 }
1440 default:
1441 archiveMailbox = defaultArchiveMailbox
1442 }
1443
1444 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1445 return err
1446 }
1447
1448 uidSet := imap.UIDSetNum(imap.UID(uid))
1449 _, err = c.Move(uidSet, archiveMailbox).Wait()
1450 return err
1451}
1452
1453// Batch operations for multiple emails
1454
1455// DeleteEmailsFromMailbox deletes multiple emails from a mailbox (batch operation)
1456func DeleteEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1457 if len(uids) == 0 {
1458 return nil
1459 }
1460
1461 if hasBackendProvider(account) {
1462 p, err := newBackendProvider(account)
1463 if err != nil {
1464 return err
1465 }
1466 defer p.Close() //nolint:errcheck
1467 return p.DeleteEmails(context.Background(), mailbox, uids)
1468 }
1469
1470 c, err := connect(account)
1471 if err != nil {
1472 return err
1473 }
1474 defer c.Close() //nolint:errcheck
1475
1476 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1477 return err
1478 }
1479
1480 uidSet := uidsToUIDSet(uids)
1481 if err := c.Store(uidSet, &imap.StoreFlags{
1482 Op: imap.StoreFlagsAdd,
1483 Silent: true,
1484 Flags: []imap.Flag{imap.FlagDeleted},
1485 }, nil).Close(); err != nil {
1486 return err
1487 }
1488
1489 return c.Expunge().Close()
1490}
1491
1492// ArchiveEmailsFromMailbox archives multiple emails from a mailbox (batch operation)
1493func ArchiveEmailsFromMailbox(account *config.Account, mailbox string, uids []uint32) error {
1494 if len(uids) == 0 {
1495 return nil
1496 }
1497
1498 if hasBackendProvider(account) {
1499 p, err := newBackendProvider(account)
1500 if err != nil {
1501 return err
1502 }
1503 defer p.Close() //nolint:errcheck
1504 return p.ArchiveEmails(context.Background(), mailbox, uids)
1505 }
1506
1507 c, err := connect(account)
1508 if err != nil {
1509 return err
1510 }
1511 defer c.Close() //nolint:errcheck
1512
1513 var archiveMailbox string
1514 switch account.ServiceProvider {
1515 case config.ProviderGmail:
1516 archiveMailbox, err = getMailboxByAttr(c, imap.MailboxAttrAll)
1517 if err != nil {
1518 archiveMailbox = "[Gmail]/All Mail"
1519 }
1520 default:
1521 archiveMailbox = defaultArchiveMailbox
1522 }
1523
1524 if _, err := c.Select(mailbox, nil).Wait(); err != nil {
1525 return err
1526 }
1527
1528 uidSet := uidsToUIDSet(uids)
1529 _, err = c.Move(uidSet, archiveMailbox).Wait()
1530 return err
1531}
1532
1533// MoveEmailsToFolder moves multiple emails to a different folder (batch operation)
1534func MoveEmailsToFolder(account *config.Account, uids []uint32, sourceFolder, destFolder string) error {
1535 if len(uids) == 0 {
1536 return nil
1537 }
1538
1539 if hasBackendProvider(account) {
1540 p, err := newBackendProvider(account)
1541 if err != nil {
1542 return err
1543 }
1544 defer p.Close() //nolint:errcheck
1545 return p.MoveEmails(context.Background(), uids, sourceFolder, destFolder)
1546 }
1547
1548 c, err := connect(account)
1549 if err != nil {
1550 return err
1551 }
1552 defer c.Close() //nolint:errcheck
1553
1554 if _, err := c.Select(sourceFolder, nil).Wait(); err != nil {
1555 return err
1556 }
1557
1558 uidSet := uidsToUIDSet(uids)
1559 _, err = c.Move(uidSet, destFolder).Wait()
1560 return err
1561}
1562
1563// Convenience wrappers defaulting to INBOX for existing call sites.
1564
1565func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1566 return FetchMailboxEmails(account, "INBOX", limit, offset)
1567}
1568
1569func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1570 return FetchMailboxEmails(account, getSentMailbox(account), limit, offset)
1571}
1572
1573func FetchEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1574 return FetchEmailBodyFromMailbox(account, "INBOX", uid)
1575}
1576
1577func FetchSentEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1578 return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid)
1579}
1580
1581func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1582 return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding)
1583}
1584
1585func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1586 return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding)
1587}
1588
1589func DeleteEmail(account *config.Account, uid uint32) error {
1590 return DeleteEmailFromMailbox(account, "INBOX", uid)
1591}
1592
1593func DeleteSentEmail(account *config.Account, uid uint32) error {
1594 return DeleteEmailFromMailbox(account, getSentMailbox(account), uid)
1595}
1596
1597func ArchiveEmail(account *config.Account, uid uint32) error {
1598 return ArchiveEmailFromMailbox(account, "INBOX", uid)
1599}
1600
1601func ArchiveSentEmail(account *config.Account, uid uint32) error {
1602 return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid)
1603}
1604
1605// AppendToSentMailbox appends a raw RFC822 message to the Sent mailbox via IMAP APPEND.
1606func AppendToSentMailbox(account *config.Account, rawMsg []byte) error {
1607 c, err := connect(account)
1608 if err != nil {
1609 return err
1610 }
1611 defer c.Close() //nolint:errcheck
1612
1613 sentMailbox := getSentMailbox(account)
1614 appendCmd := c.Append(sentMailbox, int64(len(rawMsg)), &imap.AppendOptions{
1615 Flags: []imap.Flag{imap.FlagSeen},
1616 Time: time.Now(),
1617 })
1618 if _, err := appendCmd.Write(rawMsg); err != nil {
1619 return err
1620 }
1621 if err := appendCmd.Close(); err != nil {
1622 return err
1623 }
1624 _, err = appendCmd.Wait()
1625 return err
1626}
1627
1628// getTrashMailbox returns the trash mailbox name for the account
1629func getTrashMailbox(account *config.Account) string {
1630 switch account.ServiceProvider {
1631 case config.ProviderGmail:
1632 return "[Gmail]/Trash"
1633 case "outlook":
1634 return "Deleted Items"
1635 case "icloud":
1636 return "Deleted Messages"
1637 default:
1638 return "Trash"
1639 }
1640}
1641
1642// getArchiveMailbox returns the archive/all mail mailbox name for the account
1643func getArchiveMailbox(account *config.Account) string {
1644 switch account.ServiceProvider {
1645 case config.ProviderGmail:
1646 return "[Gmail]/All Mail"
1647 case "outlook", "icloud":
1648 return defaultArchiveMailbox
1649 default:
1650 return defaultArchiveMailbox
1651 }
1652}
1653
1654// FetchTrashEmails fetches emails from the trash folder
1655func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1656 c, err := connect(account)
1657 if err != nil {
1658 return nil, err
1659 }
1660 defer c.Close() //nolint:errcheck
1661
1662 // Try to find trash by attribute first
1663 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1664 if err != nil {
1665 // Fallback to hardcoded path
1666 trashMailbox = getTrashMailbox(account)
1667 }
1668
1669 return FetchMailboxEmails(account, trashMailbox, limit, offset)
1670}
1671
1672// FetchArchiveEmails fetches emails from the archive/all mail folder
1673// Archive contains all emails, so we match where user is sender OR recipient
1674func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) {
1675 c, err := connect(account)
1676 if err != nil {
1677 return nil, err
1678 }
1679 defer c.Close() //nolint:errcheck
1680
1681 // Try to find archive by attribute first (Gmail uses \All)
1682 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1683 if err != nil {
1684 // Fallback to hardcoded path
1685 archiveMailbox = getArchiveMailbox(account)
1686 }
1687
1688 selectData, err := c.Select(archiveMailbox, nil).Wait()
1689 if err != nil {
1690 return nil, err
1691 }
1692
1693 if selectData.NumMessages == 0 {
1694 return []Email{}, nil
1695 }
1696
1697 to := selectData.NumMessages - offset
1698 from := uint32(1)
1699 if to > limit {
1700 from = to - limit + 1
1701 }
1702
1703 if to < 1 {
1704 return []Email{}, nil
1705 }
1706
1707 var seqset imap.SeqSet
1708 seqset.AddRange(from, to)
1709
1710 // Delivery header section for matching auto-forwarded emails
1711 deliveryHeaderSection := &imap.FetchItemBodySection{
1712 Specifier: imap.PartSpecifierHeader,
1713 HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To", "References"},
1714 Peek: true,
1715 }
1716
1717 fetchCmd := c.Fetch(seqset, &imap.FetchOptions{
1718 Envelope: true,
1719 UID: true,
1720 Flags: true,
1721 BodySection: []*imap.FetchItemBodySection{deliveryHeaderSection},
1722 })
1723 msgs, err := fetchCmd.Collect()
1724 if err != nil {
1725 return nil, err
1726 }
1727
1728 // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email
1729 fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail))
1730 if fetchEmail == "" {
1731 fetchEmail = strings.ToLower(strings.TrimSpace(account.Email))
1732 }
1733
1734 var emails []Email
1735 for _, msg := range msgs {
1736 if msg.Envelope == nil {
1737 continue
1738 }
1739
1740 var fromAddr string
1741 if len(msg.Envelope.From) > 0 {
1742 fromAddr = formatAddress(msg.Envelope.From[0])
1743 }
1744
1745 var toAddrList []string
1746 for _, addr := range msg.Envelope.To {
1747 toAddrList = append(toAddrList, addr.Addr())
1748 }
1749 for _, addr := range msg.Envelope.Cc {
1750 toAddrList = append(toAddrList, addr.Addr())
1751 }
1752
1753 // For archive/All Mail, match emails where user is sender OR recipient
1754 matched := false
1755 if account.CatchAll {
1756 matched = true
1757 } else {
1758 // Check if user is the sender
1759 if addressMatches(fromAddr, fetchEmail, account) {
1760 matched = true
1761 }
1762 // Check if user is a recipient
1763 if !matched {
1764 for _, r := range toAddrList {
1765 if addressMatches(r, fetchEmail, account) {
1766 matched = true
1767 break
1768 }
1769 }
1770 }
1771 // Check delivery headers for auto-forwarded emails
1772 if !matched {
1773 headerData := msg.FindBodySection(deliveryHeaderSection)
1774 matched = deliveryHeadersMatch(headerData, fetchEmail, account)
1775 }
1776 }
1777
1778 if !matched {
1779 continue
1780 }
1781
1782 headerData := msg.FindBodySection(deliveryHeaderSection)
1783 emails = append(emails, Email{
1784 UID: uint32(msg.UID),
1785 From: fromAddr,
1786 To: toAddrList,
1787 Subject: decodeHeader(msg.Envelope.Subject),
1788 Date: msg.Envelope.Date,
1789 IsRead: hasSeenFlag(msg.Flags),
1790 MessageID: msg.Envelope.MessageID,
1791 InReplyTo: firstEnvelopeInReplyTo(msg.Envelope.InReplyTo),
1792 References: headerMessageIDs(headerData, "References"),
1793 AccountID: account.ID,
1794 })
1795 }
1796
1797 // Reverse to get newest first
1798 for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
1799 emails[i], emails[j] = emails[j], emails[i]
1800 }
1801
1802 return emails, nil
1803}
1804
1805// FetchTrashEmailBody fetches the body of an email from trash
1806func FetchTrashEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1807 c, err := connect(account)
1808 if err != nil {
1809 return "", "", nil, err
1810 }
1811 defer c.Close() //nolint:errcheck
1812
1813 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1814 if err != nil {
1815 trashMailbox = getTrashMailbox(account)
1816 }
1817
1818 return FetchEmailBodyFromMailbox(account, trashMailbox, uid)
1819}
1820
1821// FetchArchiveEmailBody fetches the body of an email from archive
1822func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) {
1823 c, err := connect(account)
1824 if err != nil {
1825 return "", "", nil, err
1826 }
1827 defer c.Close() //nolint:errcheck
1828
1829 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1830 if err != nil {
1831 archiveMailbox = getArchiveMailbox(account)
1832 }
1833
1834 return FetchEmailBodyFromMailbox(account, archiveMailbox, uid)
1835}
1836
1837// FetchTrashAttachment fetches an attachment from trash
1838func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1839 c, err := connect(account)
1840 if err != nil {
1841 return nil, err
1842 }
1843 defer c.Close() //nolint:errcheck
1844
1845 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1846 if err != nil {
1847 trashMailbox = getTrashMailbox(account)
1848 }
1849
1850 return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding)
1851}
1852
1853// FetchArchiveAttachment fetches an attachment from archive
1854func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) {
1855 c, err := connect(account)
1856 if err != nil {
1857 return nil, err
1858 }
1859 defer c.Close() //nolint:errcheck
1860
1861 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1862 if err != nil {
1863 archiveMailbox = getArchiveMailbox(account)
1864 }
1865
1866 return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding)
1867}
1868
1869// DeleteTrashEmail permanently deletes an email from trash
1870func DeleteTrashEmail(account *config.Account, uid uint32) error {
1871 c, err := connect(account)
1872 if err != nil {
1873 return err
1874 }
1875 defer c.Close() //nolint:errcheck
1876
1877 trashMailbox, err := getMailboxByAttr(c, imap.MailboxAttrTrash)
1878 if err != nil {
1879 trashMailbox = getTrashMailbox(account)
1880 }
1881
1882 return DeleteEmailFromMailbox(account, trashMailbox, uid)
1883}
1884
1885// DeleteArchiveEmail deletes an email from archive (moves to trash)
1886func DeleteArchiveEmail(account *config.Account, uid uint32) error {
1887 c, err := connect(account)
1888 if err != nil {
1889 return err
1890 }
1891 defer c.Close() //nolint:errcheck
1892
1893 archiveMailbox, err := getMailboxByAttr(c, imap.MailboxAttrAll)
1894 if err != nil {
1895 archiveMailbox = getArchiveMailbox(account)
1896 }
1897
1898 return DeleteEmailFromMailbox(account, archiveMailbox, uid)
1899}
1900
1901// FetchFolders lists all IMAP folders/mailboxes for an account.
1902func FetchFolders(account *config.Account) ([]Folder, error) {
1903 if hasBackendProvider(account) {
1904 p, err := newBackendProvider(account)
1905 if err != nil {
1906 return nil, err
1907 }
1908 defer p.Close() //nolint:errcheck
1909 folders, err := p.FetchFolders(context.Background())
1910 if err != nil {
1911 return nil, err
1912 }
1913 return backendFoldersToFetcher(folders), nil
1914 }
1915
1916 c, err := connect(account)
1917 if err != nil {
1918 return nil, err
1919 }
1920 defer c.Close() //nolint:errcheck
1921
1922 listCmd := c.List("", "*", &imap.ListOptions{
1923 ReturnStatus: &imap.StatusOptions{
1924 NumUnseen: true,
1925 },
1926 })
1927 defer listCmd.Close() //nolint:errcheck
1928
1929 var folders []Folder
1930 for {
1931 data := listCmd.Next()
1932 if data == nil {
1933 break
1934 }
1935 delim := ""
1936 if data.Delim != 0 {
1937 delim = string(data.Delim)
1938 }
1939
1940 var unread uint32
1941 if data.Status != nil {
1942 unread = *data.Status.NumUnseen
1943 }
1944
1945 var attrs []string
1946 for _, a := range data.Attrs {
1947 attrs = append(attrs, string(a))
1948 }
1949 folders = append(folders, Folder{
1950 Name: data.Mailbox,
1951 Delimiter: delim,
1952 Unread: unread,
1953 Attributes: attrs,
1954 })
1955 }
1956
1957 if err := listCmd.Close(); err != nil {
1958 return nil, err
1959 }
1960
1961 return folders, nil
1962}
1963
1964// MoveEmailToFolder moves an email from one folder to another via IMAP.
1965func MoveEmailToFolder(account *config.Account, uid uint32, sourceFolder, destFolder string) error {
1966 return moveEmail(account, uid, sourceFolder, destFolder)
1967}
1968
1969// FetchFolderEmails fetches emails from an arbitrary folder.
1970func FetchFolderEmails(account *config.Account, folder string, limit, offset uint32) ([]Email, error) {
1971 return FetchMailboxEmails(account, folder, limit, offset)
1972}
1973
1974// FetchFolderEmailBody fetches the body of an email from an arbitrary folder.
1975func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, string, []Attachment, error) {
1976 return FetchEmailBodyFromMailbox(account, folder, uid)
1977}
1978
1979// FetchFolderAttachment fetches an attachment from an arbitrary folder.
1980func FetchFolderAttachment(account *config.Account, folder string, uid uint32, partID string, encoding string) ([]byte, error) {
1981 return FetchAttachmentFromMailbox(account, folder, uid, partID, encoding)
1982}
1983
1984// DeleteFolderEmail deletes an email from an arbitrary folder.
1985func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error {
1986 return DeleteEmailFromMailbox(account, folder, uid)
1987}
1988
1989// ArchiveFolderEmail archives an email from an arbitrary folder.
1990func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error {
1991 return ArchiveEmailFromMailbox(account, folder, uid)
1992}
1993
1994// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key.
1995func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) {
1996 if account.PGPPrivateKey == "" {
1997 return nil, errors.New("PGP private key not configured")
1998 }
1999
2000 // Load private key
2001 keyFile, err := os.ReadFile(account.PGPPrivateKey)
2002 if err != nil {
2003 return nil, fmt.Errorf("failed to read PGP private key: %w", err)
2004 }
2005
2006 // Try armored format first
2007 entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile))
2008 if err != nil {
2009 // Try binary format
2010 entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile))
2011 if err != nil {
2012 return nil, fmt.Errorf("failed to parse PGP private key: %w", err)
2013 }
2014 }
2015
2016 if len(entityList) == 0 {
2017 return nil, errors.New("no PGP keys found in private keyring")
2018 }
2019
2020 // Decrypt using go-pgpmail
2021 mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil)
2022 if err != nil {
2023 return nil, fmt.Errorf("failed to decrypt PGP message: %w", err)
2024 }
2025
2026 // Read decrypted content from UnverifiedBody
2027 if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil {
2028 return nil, errors.New("no decrypted content available")
2029 }
2030
2031 var decrypted bytes.Buffer
2032 if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil {
2033 return nil, fmt.Errorf("failed to read decrypted content: %w", err)
2034 }
2035
2036 return decrypted.Bytes(), nil
2037}
2038
2039// loadPGPKeyring builds an openpgp.EntityList from the account's public key
2040// and any keys stored in the pgp/ config directory.
2041func loadPGPKeyring(account *config.Account) openpgp.EntityList {
2042 var keyring openpgp.EntityList
2043
2044 readKeys := func(path string) {
2045 data, err := os.ReadFile(path)
2046 if err != nil {
2047 return
2048 }
2049 entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
2050 if err != nil {
2051 entities, err = openpgp.ReadKeyRing(bytes.NewReader(data))
2052 if err != nil {
2053 return
2054 }
2055 }
2056 keyring = append(keyring, entities...)
2057 }
2058
2059 // Load account's own public key
2060 if account.PGPPublicKey != "" {
2061 readKeys(account.PGPPublicKey)
2062 }
2063
2064 // Load all keys from the pgp/ config directory
2065 cfgDir, err := config.GetConfigDir()
2066 if err == nil {
2067 pgpDir := cfgDir + "/pgp"
2068 entries, err := os.ReadDir(pgpDir)
2069 if err == nil {
2070 for _, entry := range entries {
2071 if entry.IsDir() {
2072 continue
2073 }
2074 name := entry.Name()
2075 if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") {
2076 readKeys(pgpDir + "/" + name)
2077 }
2078 }
2079 }
2080 }
2081
2082 return keyring
2083}
2084
2085// verifyPGPSignature verifies a PGP detached signature against signed content.
2086func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool {
2087 keyring := loadPGPKeyring(account)
2088 if len(keyring) == 0 {
2089 return false
2090 }
2091
2092 // Build a complete multipart/signed message for go-pgpmail
2093 boundary := "pgp-verify-boundary"
2094 var msg bytes.Buffer
2095 msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n")
2096 msg.WriteString("--" + boundary + "\r\n")
2097 msg.Write(signedContent)
2098 msg.WriteString("\r\n--" + boundary + "\r\n")
2099 msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n")
2100 msg.Write(signatureData)
2101 msg.WriteString("\r\n--" + boundary + "--\r\n")
2102
2103 mr, err := pgpmail.Read(&msg, keyring, nil, nil)
2104 if err != nil {
2105 return false
2106 }
2107
2108 if mr.MessageDetails == nil {
2109 return false
2110 }
2111
2112 // Must read UnverifiedBody to EOF to trigger signature verification
2113 _, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody)
2114
2115 return mr.MessageDetails.SignatureError == nil
2116}