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 }