fix: add delivery header check (#479)

Drew Smirnoff created

Change summary

fetcher/fetcher.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 49 insertions(+), 2 deletions(-)

Detailed changes

fetcher/fetcher.go 🔗

@@ -1,6 +1,7 @@
 package fetcher
 
 import (
+	"bufio"
 	"bytes"
 	"crypto/tls"
 	"crypto/x509"
@@ -12,6 +13,7 @@ import (
 	"io/ioutil"
 	"mime"
 	"mime/quotedprintable"
+	"net/textproto"
 	"os"
 	"slices"
 	"strings"
@@ -80,6 +82,37 @@ func hasSeenFlag(flags []string) bool {
 	return slices.Contains(flags, imap.SeenFlag)
 }
 
+// deliveryHeadersMatch checks if any of the Delivered-To, X-Forwarded-To, or
+// X-Original-To headers contain the given email address. This catches
+// auto-forwarded emails where the envelope To/Cc don't match the local account.
+func deliveryHeadersMatch(msg *imap.Message, section *imap.BodySectionName, fetchEmail string) bool {
+	if section == nil {
+		return false
+	}
+	literal := msg.GetBody(section)
+	if literal == nil {
+		return false
+	}
+	data, err := ioutil.ReadAll(literal)
+	if err != nil || len(data) == 0 {
+		return false
+	}
+	// Parse as MIME headers
+	reader := textproto.NewReader(bufio.NewReader(bytes.NewReader(data)))
+	headers, err := reader.ReadMIMEHeader()
+	if err != nil && len(headers) == 0 {
+		return false
+	}
+	for _, key := range []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"} {
+		for _, val := range headers.Values(key) {
+			if strings.EqualFold(strings.TrimSpace(val), fetchEmail) {
+				return true
+			}
+		}
+	}
+	return false
+}
+
 func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 	if err != nil {
@@ -283,7 +316,10 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 
 		messages := make(chan *imap.Message, chunkSize)
 		done := make(chan error, 1)
-		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
+		// Fetch delivery headers to match auto-forwarded emails (Delivered-To, X-Forwarded-To, X-Original-To)
+		deliveryHeaderItem := imap.FetchItem("BODY.PEEK[HEADER.FIELDS (Delivered-To X-Forwarded-To X-Original-To)]")
+		deliveryHeaderSection, _ := imap.ParseBodySectionName(deliveryHeaderItem)
+		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags, deliveryHeaderItem}
 
 		go func() {
 			done <- c.Fetch(seqset, fetchItems, messages)
@@ -334,6 +370,10 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 						break
 					}
 				}
+				// Check delivery headers for auto-forwarded emails
+				if !matched {
+					matched = deliveryHeadersMatch(msg, deliveryHeaderSection, fetchEmail)
+				}
 			}
 
 			if !matched {
@@ -1224,7 +1264,10 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
 
 	messages := make(chan *imap.Message, limit)
 	done := make(chan error, 1)
-	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
+	// Fetch delivery headers to match auto-forwarded emails
+	deliveryHeaderItem := imap.FetchItem("BODY.PEEK[HEADER.FIELDS (Delivered-To X-Forwarded-To X-Original-To)]")
+	deliveryHeaderSection, _ := imap.ParseBodySectionName(deliveryHeaderItem)
+	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags, deliveryHeaderItem}
 	go func() {
 		done <- c.Fetch(seqset, fetchItems, messages)
 	}()
@@ -1278,6 +1321,10 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
 				}
 			}
 		}
+		// Check delivery headers for auto-forwarded emails
+		if !matched {
+			matched = deliveryHeadersMatch(msg, deliveryHeaderSection, fetchEmail)
+		}
 
 		if !matched {
 			continue