Files
LambdaMail/services/protocols/internal/infrastructure/postgres/conversation_thread.go
T
Lucas RayanandClaude Sonnet 5.5 67d0c8aa5a feat(protocols): conversations - thread_id and in_reply_to written at delivery, list and read APIs
A reply now joins the conversation of the message it answers (Message-ID,
In-Reply-To, References), falling back to the subject for replies with no
headers. /mail/threads lists a folder as conversations and /mail/thread/{id}
returns one. Replying from the webmail flags the answered message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 16:38:36 -03:00

97 lines
3.5 KiB
Go

package postgres
import (
"context"
"crypto/md5"
"errors"
"fmt"
"regexp"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"lambdamail/protocols/internal/application/port"
)
// resolveThread decides which conversation a message being filed belongs to.
//
// In order of how much each is to be trusted:
//
// 1. A message already in this mailbox with the same Message-ID. The same
// message filed twice - a copy in Sent and one delivered back to oneself,
// or a copy moved to another folder - is one conversation, not two.
// 2. The message it answers, found through In-Reply-To and then References.
// This is what actually ties a reply to its original.
// 3. A subject that starts with Re:, Fwd: or a translation of them, when the
// headers found nothing. The same key the migration used for older mail,
// so a reply to a message from before this existed still lands with it.
// 4. Otherwise it starts a conversation of its own.
//
// A message with no stored thread_id is read as a conversation of its own
// (COALESCE(thread_id, id)), so a parent from before threading is adopted by
// its own id and needs no rewrite.
func resolveThread(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessageInput) (uuid.UUID, error) {
// The message's own id goes first so that it outranks the ones it answers.
var candidates []string
if input.MessageIDHeader != "" {
candidates = append(candidates, input.MessageIDHeader)
}
parents := make([]string, 0, len(input.References)+1)
if input.InReplyTo != "" {
parents = append(parents, input.InReplyTo)
}
// Nearest ancestor first: the last reference is the direct parent.
for i := len(input.References) - 1; i >= 0; i-- {
parents = append(parents, input.References[i])
}
candidates = append(candidates, parents...)
if len(candidates) > 0 {
var found uuid.UUID
err := tx.QueryRow(ctx, `
SELECT COALESCE(thread_id, id)
FROM email_messages
WHERE mailbox_id = $1
AND message_id_header = ANY($2)
AND expunged_at IS NULL
ORDER BY array_position($2::text[], message_id_header), received_at DESC
LIMIT 1`, input.MailboxID, candidates).Scan(&found)
if err == nil {
return found, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, fmt.Errorf("find the conversation: %w", err)
}
}
if key := subjectKey(input.Subject); hasReplyPrefix(input.Subject) && key != "" {
return derivedThread(input.MailboxID, key), nil
}
return uuid.New(), nil
}
// The subject helpers are written here as well as in the migration's SQL
// functions on purpose: delivery must keep working against a database whose
// migrations have not all run, so it cannot call functions a later migration
// creates. A test holds the two spellings to the same answers.
var replyPrefix = regexp.MustCompile(`^(\s*(re|fw|fwd|enc|res|rv|aw|wg|tr|antw)(\[\d+\])?\s*:)+`)
func hasReplyPrefix(subject string) bool {
return replyPrefix.MatchString(strings.ToLower(subject))
}
// subjectKey is the subject with the prefixes mail clients add taken off.
func subjectKey(subject string) string {
return strings.TrimSpace(replyPrefix.ReplaceAllString(strings.ToLower(subject), ""))
}
// derivedThread names the conversation a subject belongs to when nothing in
// the headers does. md5 over the same text as the migration's backfill, so
// mail filed before and after agree on it.
func derivedThread(mailboxID uuid.UUID, key string) uuid.UUID {
sum := md5.Sum([]byte(mailboxID.String() + "|" + key))
id, _ := uuid.FromBytes(sum[:])
return id
}