package threading import ( "context" "errors" "regexp" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var messageIDToken = regexp.MustCompile(`<[^>]+>`) // NormalizeMessageID returns a canonical angle-bracket Message-ID when possible. func NormalizeMessageID(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if strings.HasPrefix(raw, "<") && strings.HasSuffix(raw, ">") { return raw } return "<" + strings.Trim(raw, "<>") + ">" } // ParseMessageIDs extracts Message-IDs from a References or In-Reply-To header value. func ParseMessageIDs(header string) []string { header = strings.TrimSpace(header) if header == "" { return nil } matches := messageIDToken.FindAllString(header, -1) if len(matches) == 0 { if id := NormalizeMessageID(header); id != "" { return []string{id} } return nil } seen := make(map[string]struct{}, len(matches)) out := make([]string, 0, len(matches)) for _, m := range matches { id := NormalizeMessageID(m) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out } // BuildReferences returns the References chain for a reply (ancestors + parent). func BuildReferences(parentMessageID string, parentReferences []string) []string { parentMessageID = NormalizeMessageID(parentMessageID) if parentMessageID == "" { return nil } seen := make(map[string]struct{}, len(parentReferences)+1) out := make([]string, 0, len(parentReferences)+1) for _, ref := range parentReferences { id := NormalizeMessageID(ref) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } if _, ok := seen[parentMessageID]; !ok { out = append(out, parentMessageID) } return out } func candidateMessageIDs(inReplyTo string, references []string) []string { seen := make(map[string]struct{}) var out []string add := func(id string) { id = NormalizeMessageID(id) if id == "" { return } if _, ok := seen[id]; ok { return } seen[id] = struct{}{} out = append(out, id) } add(inReplyTo) for _, ref := range references { add(ref) } return out } // AssignThreadID picks an existing thread for the account or allocates a new one. func AssignThreadID(ctx context.Context, db *pgxpool.Pool, accountID, inReplyTo string, references []string) (string, error) { ids := candidateMessageIDs(inReplyTo, references) if len(ids) > 0 { var threadID *uuid.UUID err := db.QueryRow(ctx, ` SELECT thread_id FROM messages WHERE account_id = $1 AND message_id = ANY($2) AND thread_id IS NOT NULL ORDER BY date ASC LIMIT 1 `, accountID, ids).Scan(&threadID) if err == nil && threadID != nil { return threadID.String(), nil } if err != nil && !errors.Is(err, pgx.ErrNoRows) { return "", err } } return uuid.New().String(), nil }