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,308 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
type threadFixture struct {
|
||||
pool *pgxpool.Pool
|
||||
repo *InboundMessageRepository
|
||||
mailboxID uuid.UUID
|
||||
domain string
|
||||
blob port.BlobRef
|
||||
}
|
||||
|
||||
func newThreadFixture(t *testing.T) *threadFixture {
|
||||
t.Helper()
|
||||
pool := testPool(t)
|
||||
t.Cleanup(pool.Close)
|
||||
ctx := context.Background()
|
||||
|
||||
domainID := uuid.New()
|
||||
name := "thread-test-" + domainID.String() + ".invalid"
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`, domainID, name, name); err != nil {
|
||||
t.Fatalf("seed domain: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { pool.Exec(ctx, `DELETE FROM domains WHERE id = $1`, domainID) })
|
||||
|
||||
mailboxID := uuid.New()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO mailboxes (id, domain_id, local_part, email_address, password_hash) VALUES ($1, $2, 'me', $3, 'x')`,
|
||||
mailboxID, domainID, "me@"+name); err != nil {
|
||||
t.Fatalf("seed mailbox: %v", err)
|
||||
}
|
||||
for _, f := range [][2]string{{"INBOX", "inbox"}, {"Sent", "sent"}} {
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO folders (id, mailbox_id, name, special_use) VALUES ($1, $2, $3, $4)`,
|
||||
uuid.New(), mailboxID, f[0], f[1]); err != nil {
|
||||
t.Fatalf("seed folder %s: %v", f[0], err)
|
||||
}
|
||||
}
|
||||
blobID := uuid.New()
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO message_blobs (id, content_sha256, storage_driver, storage_path, size_bytes) VALUES ($1, $2, 'local', '/tmp/x', 10)`,
|
||||
blobID, testBlobDigest()); err != nil {
|
||||
t.Fatalf("seed blob: %v", err)
|
||||
}
|
||||
return &threadFixture{pool: pool, repo: NewInboundMessageRepository(pool), mailboxID: mailboxID, domain: name,
|
||||
blob: port.BlobRef{ID: blobID, SHA256: "deadbeef", SizeBytes: 10}}
|
||||
}
|
||||
|
||||
// file stores one message and returns the conversation it landed in.
|
||||
func (f *threadFixture) file(t *testing.T, in port.PersistInboundMessageInput) uuid.UUID {
|
||||
t.Helper()
|
||||
in.MailboxID = f.mailboxID
|
||||
in.Blob = f.blob
|
||||
if in.SenderAddress == "" {
|
||||
in.SenderAddress = "someone@example.test"
|
||||
}
|
||||
in.RecipientAddress = "me@" + f.domain
|
||||
in.SPFResult, in.DKIMResult, in.DMARCResult = "none", "none", "none"
|
||||
if _, err := f.repo.PersistAll(context.Background(), []port.PersistInboundMessageInput{in}); err != nil {
|
||||
t.Fatalf("PersistAll: %v", err)
|
||||
}
|
||||
var thread uuid.UUID
|
||||
err := f.pool.QueryRow(context.Background(), `
|
||||
SELECT COALESCE(thread_id, id) FROM email_messages
|
||||
WHERE mailbox_id = $1 AND subject = $2 ORDER BY received_at DESC, uid DESC LIMIT 1`,
|
||||
f.mailboxID, in.Subject).Scan(&thread)
|
||||
if err != nil {
|
||||
t.Fatalf("read the conversation back: %v", err)
|
||||
}
|
||||
return thread
|
||||
}
|
||||
|
||||
func TestAReplyJoinsTheMessageItAnswers(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
root := f.file(t, port.PersistInboundMessageInput{Subject: "Lunch?", MessageIDHeader: "a@x.test"})
|
||||
reply := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Lunch?", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
if reply != root {
|
||||
t.Fatalf("the reply started a conversation of its own")
|
||||
}
|
||||
}
|
||||
|
||||
// The reply carries no Re: and a different subject, so only the headers can
|
||||
// tie it to the conversation.
|
||||
func TestHeadersOutrankTheSubject(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
root := f.file(t, port.PersistInboundMessageInput{Subject: "Lunch?", MessageIDHeader: "a@x.test"})
|
||||
reply := f.file(t, port.PersistInboundMessageInput{Subject: "Actually dinner", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
if reply != root {
|
||||
t.Fatalf("a reply with a changed subject left the conversation")
|
||||
}
|
||||
}
|
||||
|
||||
// Only References survives some clients, and a long chain names the parent last.
|
||||
func TestReferencesAloneFindTheConversation(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
root := f.file(t, port.PersistInboundMessageInput{Subject: "Plan", MessageIDHeader: "a@x.test"})
|
||||
f.file(t, port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
third := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Re: Plan", MessageIDHeader: "c@x.test",
|
||||
References: []string{"a@x.test", "b@x.test"}})
|
||||
if third != root {
|
||||
t.Fatalf("a message known only by References left the conversation")
|
||||
}
|
||||
}
|
||||
|
||||
// What was sent and what came back are one conversation.
|
||||
func TestTheSentCopyJoinsTheConversation(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
root := f.file(t, port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"})
|
||||
sent := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "mine@x.test",
|
||||
InReplyTo: "a@x.test", TargetFolderName: "Sent", AlreadySeen: true})
|
||||
if sent != root {
|
||||
t.Fatalf("my own reply is not in the conversation it answers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnrelatedMessagesStayApart(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
one := f.file(t, port.PersistInboundMessageInput{Subject: "Invoice", MessageIDHeader: "a@x.test"})
|
||||
two := f.file(t, port.PersistInboundMessageInput{Subject: "Invoice", MessageIDHeader: "b@x.test"})
|
||||
if one == two {
|
||||
t.Fatalf("two unrelated messages that share a subject were merged")
|
||||
}
|
||||
}
|
||||
|
||||
// A reply that names nothing is still a reply by its subject.
|
||||
func TestAReplyWithNoHeadersFallsBackToItsSubject(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
a := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Status"})
|
||||
b := f.file(t, port.PersistInboundMessageInput{Subject: "RE: Re: STATUS"})
|
||||
if a != b {
|
||||
t.Fatalf("replies to the same subject were not grouped")
|
||||
}
|
||||
}
|
||||
|
||||
// The same message filed twice, such as a copy sent to oneself, is one conversation.
|
||||
func TestTheSameMessageFiledTwiceIsOneConversation(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
a := f.file(t, port.PersistInboundMessageInput{Subject: "Note", MessageIDHeader: "n@x.test", TargetFolderName: "Sent", AlreadySeen: true})
|
||||
b := f.file(t, port.PersistInboundMessageInput{Subject: "Note", MessageIDHeader: "n@x.test"})
|
||||
if a != b {
|
||||
t.Fatalf("the sent copy and the delivered copy are two conversations")
|
||||
}
|
||||
}
|
||||
|
||||
// Mail from before threading has no thread_id and is its own conversation, so
|
||||
// a reply must adopt it by id rather than orphan it.
|
||||
func TestAReplyAdoptsAMessageFromBeforeThreading(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.file(t, port.PersistInboundMessageInput{Subject: "Old mail", MessageIDHeader: "old@x.test"})
|
||||
if _, err := f.pool.Exec(context.Background(), `UPDATE email_messages SET thread_id = NULL WHERE mailbox_id = $1`, f.mailboxID); err != nil {
|
||||
t.Fatalf("simulate legacy: %v", err)
|
||||
}
|
||||
var legacyID uuid.UUID
|
||||
if err := f.pool.QueryRow(context.Background(), `SELECT id FROM email_messages WHERE mailbox_id = $1`, f.mailboxID).Scan(&legacyID); err != nil {
|
||||
t.Fatalf("read legacy id: %v", err)
|
||||
}
|
||||
reply := f.file(t, port.PersistInboundMessageInput{Subject: "Re: Old mail", MessageIDHeader: "new@x.test", InReplyTo: "old@x.test"})
|
||||
if reply != legacyID {
|
||||
t.Fatalf("the reply did not adopt the older message")
|
||||
}
|
||||
}
|
||||
|
||||
// --- reading conversations --------------------------------------------------
|
||||
|
||||
func (f *threadFixture) own(t *testing.T, in port.PersistInboundMessageInput) {
|
||||
t.Helper()
|
||||
in.TargetFolderName = "Sent"
|
||||
in.AlreadySeen = true
|
||||
in.SenderAddress = "me@" + f.domain
|
||||
f.file(t, in)
|
||||
}
|
||||
|
||||
func (f *threadFixture) inbox(t *testing.T, from string, in port.PersistInboundMessageInput) {
|
||||
t.Helper()
|
||||
in.SenderAddress = from
|
||||
f.file(t, in)
|
||||
}
|
||||
|
||||
func TestTheListHasOneRowPerConversationWithItsCount(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Plan", MessageIDHeader: "a@x.test"})
|
||||
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Re: Plan", MessageIDHeader: "c@x.test", InReplyTo: "b@x.test"})
|
||||
f.inbox(t, "bob@x.test", port.PersistInboundMessageInput{Subject: "Other", MessageIDHeader: "z@x.test"})
|
||||
|
||||
threads, err := NewConversations(f.pool).ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListThreads: %v", err)
|
||||
}
|
||||
if len(threads) != 2 {
|
||||
t.Fatalf("got %d rows, want 2 conversations", len(threads))
|
||||
}
|
||||
plan := threads[0]
|
||||
if !strings.Contains(plan.Subject, "Plan") {
|
||||
plan = threads[1]
|
||||
}
|
||||
// The reply written from Sent counts towards the conversation shown in the inbox.
|
||||
if plan.MessageCount != 3 {
|
||||
t.Errorf("the conversation has %d messages, want 3 (two received, one sent)", plan.MessageCount)
|
||||
}
|
||||
}
|
||||
|
||||
// What a reader wants to know at a glance.
|
||||
func TestAConversationIsAnsweredWhenIReplied(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"})
|
||||
conv := NewConversations(f.pool)
|
||||
before, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
if before[0].Answered {
|
||||
t.Fatalf("an unanswered conversation was shown as answered")
|
||||
}
|
||||
|
||||
// A reply written in another mail program leaves no \Answered flag, only the message.
|
||||
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
after, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
if !after[0].Answered {
|
||||
t.Fatalf("the conversation I replied to is not shown as answered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewMessageAfterMyReplyIsUnansweredAgain(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Quote", MessageIDHeader: "a@x.test"})
|
||||
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Re: Quote", MessageIDHeader: "c@x.test", InReplyTo: "b@x.test"})
|
||||
threads, _ := NewConversations(f.pool).ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
if threads[0].Answered {
|
||||
t.Fatalf("a conversation waiting on my reply was shown as answered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkingAMessageAnsweredFlagsIt(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Ask", MessageIDHeader: "a@x.test"})
|
||||
conv := NewConversations(f.pool)
|
||||
if err := conv.MarkAnsweredByMessageID(context.Background(), f.mailboxID.String(), "<a@x.test>"); err != nil {
|
||||
t.Fatalf("MarkAnsweredByMessageID: %v", err)
|
||||
}
|
||||
threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
msgs, err := conv.ThreadMessages(context.Background(), f.mailboxID.String(), "INBOX", threads[0].ThreadID)
|
||||
if err != nil || len(msgs) != 1 || !msgs[0].Answered {
|
||||
t.Fatalf("the flag did not show: %v %+v", err, msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConversationOfAnotherMailboxCannotBeRead(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Private", MessageIDHeader: "a@x.test"})
|
||||
conv := NewConversations(f.pool)
|
||||
threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
|
||||
other := newThreadFixture(t)
|
||||
if _, err := conv.ThreadMessages(context.Background(), other.mailboxID.String(), "INBOX", threads[0].ThreadID); err == nil {
|
||||
t.Fatalf("another mailbox read this conversation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadMessagesAreOldestFirstAndLeaveOutTrash(t *testing.T) {
|
||||
f := newThreadFixture(t)
|
||||
f.inbox(t, "ana@x.test", port.PersistInboundMessageInput{Subject: "Topic", MessageIDHeader: "a@x.test"})
|
||||
f.own(t, port.PersistInboundMessageInput{Subject: "Re: Topic", MessageIDHeader: "b@x.test", InReplyTo: "a@x.test"})
|
||||
conv := NewConversations(f.pool)
|
||||
threads, _ := conv.ListThreads(context.Background(), f.mailboxID.String(), "INBOX", "", 50, 0)
|
||||
msgs, err := conv.ThreadMessages(context.Background(), f.mailboxID.String(), "INBOX", threads[0].ThreadID)
|
||||
if err != nil || len(msgs) != 2 {
|
||||
t.Fatalf("got %d messages (%v), want 2", len(msgs), err)
|
||||
}
|
||||
if msgs[0].Own || !msgs[1].Own || !msgs[0].Answered {
|
||||
t.Errorf("order or flags wrong: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery computes the subject key itself, and the migration backfilled older
|
||||
// mail with SQL. If the two ever disagreed, a reply would miss the conversation
|
||||
// its older messages were put in.
|
||||
func TestGoAndSQLAgreeOnTheSubjectKey(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
mailbox := uuid.New()
|
||||
for _, subject := range []string{
|
||||
"Plan", "Re: Plan", "RE: Re[2]: Fwd: Plan ", " re :plan", "AW: Plan", "ENC: Plan", "Reunion", "Re:", "",
|
||||
"Fw:Fw: invoice 12", "Resumo: vendas", "Antw: Re: Hallo",
|
||||
} {
|
||||
var sqlKey string
|
||||
var sqlPrefix bool
|
||||
var sqlThread uuid.UUID
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT lm_subject_key($1), lm_has_reply_prefix($1), md5($2::text || '|' || lm_subject_key($1))::uuid`,
|
||||
subject, mailbox).Scan(&sqlKey, &sqlPrefix, &sqlThread); err != nil {
|
||||
t.Fatalf("%q: %v", subject, err)
|
||||
}
|
||||
if got := subjectKey(subject); got != sqlKey {
|
||||
t.Errorf("%q: Go key %q, SQL key %q", subject, got, sqlKey)
|
||||
}
|
||||
if got := hasReplyPrefix(subject); got != sqlPrefix {
|
||||
t.Errorf("%q: Go prefix %v, SQL prefix %v", subject, got, sqlPrefix)
|
||||
}
|
||||
if got := derivedThread(mailbox, sqlKey); got != sqlThread {
|
||||
t.Errorf("%q: Go thread %v, SQL thread %v", subject, got, sqlThread)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user