From b8e4002adb52e13ef09c54fd9942f35f4fa00cdf Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 4 Jul 2026 09:30:27 +0200 Subject: [PATCH] feat: fetch the warmup verify token and machine-reply headers via BODY.PEEK on IMAP sync and surface them as flag pseudo-headers, and join replies to the parent's thread instead of forking a new thread at every reply depth --- internal/app/worker/wmail/event_imap.go | 11 ++++++- internal/client/smtpimap/imap/client.go | 43 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/app/worker/wmail/event_imap.go b/internal/app/worker/wmail/event_imap.go index 4cdba3cc9..3caa4b3fd 100644 --- a/internal/app/worker/wmail/event_imap.go +++ b/internal/app/worker/wmail/event_imap.go @@ -36,7 +36,16 @@ func (w *WMail) onImapEmailUpdate(ctx context.Context, msg *models.EmailMessageD if parentID != "" { internalParent, _ := w.EmailMessageMapRepository.Get(ctx, w.UserID, w.ID, parentID) if internalParent != nil { - threadID = internalParent.ID + // Join the parent's thread; using the parent's internal id here + // forked a new thread at every reply depth. + threadID = internalParent.ThreadID + if threadID == "" { + threadID = parentID + } + } else { + // Parent unknown (e.g. reply to a pre-connect message): root a + // thread on the parent's RFC id so siblings still group. + threadID = parentID } } else { threadID = msg.MessageID diff --git a/internal/client/smtpimap/imap/client.go b/internal/client/smtpimap/imap/client.go index e5a9c4826..81daa477c 100644 --- a/internal/client/smtpimap/imap/client.go +++ b/internal/client/smtpimap/imap/client.go @@ -1,11 +1,15 @@ package imap import ( + "bufio" "context" "crypto/tls" "errors" "fmt" + "io" "net" + "net/textproto" + "strings" "sync" "github.com/emersion/go-imap/v2" @@ -19,6 +23,13 @@ import ( "golang.org/x/oauth2" ) +// headerFetchFields are the internet headers fetched alongside each changed +// message (BODY.PEEK[HEADER.FIELDS ...]) and surfaced into Flags as +// "Header:value" pseudo-flags: the warmup verification token plus the +// machine-reply/DSN markers the consumer's reply/bounce classifier reads. +// The IMAP ENVELOPE carries none of these. +var headerFetchFields = append([]string{config.WarmupVerifyHeader}, config.InboundClassificationHeaders...) + type Client struct { Email string AuthType models.AuthType @@ -166,12 +177,20 @@ func (c *Client) FetchChanges(ctx context.Context, lastModSeq uint64) *errx.Mail ModSeq: true, InternalDate: true, ChangedSince: lastModSeq, + BodySection: []*imap.FetchItemBodySection{{ + Specifier: imap.PartSpecifierHeader, + HeaderFields: headerFetchFields, + Peek: true, + }}, }) for em := cmd.Next(); em != nil; em = cmd.Next() { var email models.EmailMessageData var euid imap.UID var bodyStructure imap.BodyStructure + // Collected separately: the FLAGS item resets email.Flags and item + // order is server-dependent, so appending inline could be wiped. + var headerFlags []string for item := em.Next(); item != nil; item = em.Next() { switch item := item.(type) { @@ -202,9 +221,12 @@ func (c *Client) FetchChanges(ctx context.Context, lastModSeq uint64) *errx.Mail email.ModSeq = item.ModSeq case imapclient.FetchItemDataBodyStructure: bodyStructure = item.BodyStructure + case imapclient.FetchItemDataBodySection: + headerFlags = parseHeaderFlags(item.Literal) } } + email.Flags = append(email.Flags, headerFlags...) email.BodyPlain, email.BodyHTML = fetchTextParts(c.client, euid, bodyStructure) if err := c.OnUpdate(ctx, &email); err != nil { @@ -219,3 +241,24 @@ func (c *Client) FetchChanges(ctx context.Context, lastModSeq uint64) *errx.Mail return nil } + +// parseHeaderFlags reads a HEADER.FIELDS literal and renders the fetched +// headers as "Header:value" pseudo-flags, using the canonical names from +// headerFetchFields so the consumer's prefix matching always hits. +func parseHeaderFlags(lit io.Reader) []string { + if lit == nil { + return nil + } + tp := textproto.NewReader(bufio.NewReader(io.LimitReader(lit, 32*1024))) + hdr, err := tp.ReadMIMEHeader() + if len(hdr) == 0 && err != nil { + return nil + } + var out []string + for _, name := range headerFetchFields { + if v := strings.TrimSpace(hdr.Get(name)); v != "" { + out = append(out, name+":"+v) + } + } + return out +}