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>
97 lines
3.5 KiB
Go
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
|
|
}
|