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

This commit is contained in:
Matthew Meszaros
2026-07-04 09:30:27 +02:00
parent 3e4c3e9655
commit b8e4002adb
2 changed files with 53 additions and 1 deletions
+10 -1
View File
@@ -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
+43
View File
@@ -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
}