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>
This commit is contained in:
1 parent
ff89e9dc44
commit
67d0c8aa5a
17 files changed
+1025
-9
No files matched your search
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user