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,68 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebmailParticipant is someone who wrote in a conversation.
|
||||
type WebmailParticipant struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
// Me marks the mailbox owner, so the list can say "me" instead of a name
|
||||
// the reader knows perfectly well.
|
||||
Me bool `json:"me"`
|
||||
}
|
||||
|
||||
// WebmailThread is one row of the conversation list: the newest message in the
|
||||
// folder being looked at, with what the rest of the conversation adds.
|
||||
type WebmailThread struct {
|
||||
ThreadID string `json:"thread_id"`
|
||||
UID uint32 `json:"uid"`
|
||||
Subject string `json:"subject"`
|
||||
SenderAddress string `json:"sender_address"`
|
||||
FromName string `json:"from_display_name"`
|
||||
Snippet string `json:"snippet"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
HasAttachments bool `json:"has_attachments"`
|
||||
MessageCount int `json:"message_count"`
|
||||
UnseenCount int `json:"unseen_count"`
|
||||
Seen bool `json:"seen"`
|
||||
Flagged bool `json:"flagged"`
|
||||
Answered bool `json:"answered"`
|
||||
Participants []WebmailParticipant `json:"participants"`
|
||||
}
|
||||
|
||||
// WebmailThreadMessage is one message inside a conversation.
|
||||
type WebmailThreadMessage struct {
|
||||
UID uint32 `json:"uid"`
|
||||
Folder string `json:"folder"`
|
||||
Subject string `json:"subject"`
|
||||
SenderAddress string `json:"sender_address"`
|
||||
FromName string `json:"from_display_name"`
|
||||
Snippet string `json:"snippet"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
HasAttachments bool `json:"has_attachments"`
|
||||
Seen bool `json:"seen"`
|
||||
Flagged bool `json:"flagged"`
|
||||
// Answered is true when the mailbox owner replied, whether the \Answered
|
||||
// flag was set or the reply is simply there in the conversation - a reply
|
||||
// written in another mail program leaves no flag, only the message.
|
||||
Answered bool `json:"answered"`
|
||||
// Own marks a message the mailbox owner wrote.
|
||||
Own bool `json:"own"`
|
||||
}
|
||||
|
||||
// ConversationReader serves the conversation views.
|
||||
type ConversationReader interface {
|
||||
// ListThreads returns the conversations that have a message in the folder,
|
||||
// newest first by that message.
|
||||
ListThreads(ctx context.Context, mailboxID, folderName, search string, limit, offset int) ([]WebmailThread, error)
|
||||
// ThreadMessages returns every message of one conversation, oldest first.
|
||||
// folderName is the folder being viewed: a conversation's messages in Trash
|
||||
// and Junk are left out unless that is the folder.
|
||||
ThreadMessages(ctx context.Context, mailboxID, folderName, threadID string) ([]WebmailThreadMessage, error)
|
||||
// MarkAnsweredByMessageID sets \Answered on the message a reply was written
|
||||
// to, wherever in the mailbox it is filed.
|
||||
MarkAnsweredByMessageID(ctx context.Context, mailboxID, messageID string) error
|
||||
}
|
||||
@@ -22,6 +22,10 @@ type PersistInboundMessageInput struct {
|
||||
FromDisplayName string
|
||||
MessageIDHeader string
|
||||
HasAttachments bool
|
||||
// InReplyTo and References place the message in its conversation. Both
|
||||
// hold Message-IDs without angle brackets.
|
||||
InReplyTo string
|
||||
References []string
|
||||
// AlreadySeen marks a copy the user themselves composed - their own Sent
|
||||
// copy, or a draft. It is not unread mail, and counting it as such is why
|
||||
// the Sent folder carried an unread badge that nothing could clear: no
|
||||
|
||||
@@ -19,10 +19,15 @@ type MessageHeaders struct {
|
||||
Subject string
|
||||
FromDisplayName string
|
||||
// FromAddress is the address in From, lowercased, or empty when it does not parse.
|
||||
FromAddress string
|
||||
MessageID string
|
||||
Snippet string
|
||||
HasAttachments bool
|
||||
FromAddress string
|
||||
MessageID string
|
||||
Snippet string
|
||||
HasAttachments bool
|
||||
// InReplyTo and References are the Message-IDs this message answers, with
|
||||
// the angle brackets removed so they compare equal to MessageID. They are
|
||||
// what ties a reply to the conversation it belongs to.
|
||||
InReplyTo string
|
||||
References []string
|
||||
}
|
||||
|
||||
// wordDecoder tolerates the charsets that turn up in real mail. An unknown
|
||||
@@ -57,6 +62,10 @@ func ExtractMessageHeaders(payload []byte) MessageHeaders {
|
||||
|
||||
out.Subject = truncateUTF8(decodeHeader(msg.Header.Get("Subject")), 998)
|
||||
out.MessageID = truncateUTF8(strings.Trim(msg.Header.Get("Message-ID"), "<>"), 998)
|
||||
if ids := messageIDsIn(msg.Header.Get("In-Reply-To")); len(ids) > 0 {
|
||||
out.InReplyTo = ids[0]
|
||||
}
|
||||
out.References = messageIDsIn(msg.Header.Get("References"))
|
||||
|
||||
if from := msg.Header.Get("From"); from != "" {
|
||||
if addr, err := mail.ParseAddress(from); err == nil {
|
||||
@@ -152,3 +161,23 @@ func truncateUTF8(s string, max int) string {
|
||||
}
|
||||
return cut
|
||||
}
|
||||
|
||||
// maxReferences bounds how much of a long chain is kept. The ids nearest the
|
||||
// end name the messages closest to this one, which is where its parent is.
|
||||
const maxReferences = 20
|
||||
|
||||
// messageIDsIn lists the <id> tokens of an In-Reply-To or References header,
|
||||
// without their brackets, in the order written.
|
||||
func messageIDsIn(header string) []string {
|
||||
var ids []string
|
||||
for _, field := range strings.Fields(header) {
|
||||
id := strings.Trim(field, "<>,;")
|
||||
if id != "" && len(id) <= 998 && strings.Contains(field, "<") {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) > maxReferences {
|
||||
ids = ids[len(ids)-maxReferences:]
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
package usecase
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractMessageHeaders_ReadsSubjectSenderAndSnippet(t *testing.T) {
|
||||
msg := "From: Alice Example <alice@remote.test>\r\n" +
|
||||
@@ -142,3 +146,37 @@ func TestExtractMessageHeaders_AttachmentDetection(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadersCarryWhatAMessageAnswers(t *testing.T) {
|
||||
raw := "Message-ID: <c@x.test>\r\nIn-Reply-To: <b@x.test>\r\nReferences: <a@x.test>\r\n <b@x.test>\r\nSubject: Re: Plan\r\n\r\nbody"
|
||||
h := ExtractMessageHeaders([]byte(raw))
|
||||
if h.InReplyTo != "b@x.test" {
|
||||
t.Errorf("InReplyTo = %q, want b@x.test", h.InReplyTo)
|
||||
}
|
||||
if len(h.References) != 2 || h.References[0] != "a@x.test" || h.References[1] != "b@x.test" {
|
||||
t.Errorf("References = %v, want [a@x.test b@x.test]", h.References)
|
||||
}
|
||||
if h.MessageID != "c@x.test" {
|
||||
t.Errorf("MessageID = %q", h.MessageID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMessageThatAnswersNothingHasNoReferences(t *testing.T) {
|
||||
h := ExtractMessageHeaders([]byte("Subject: Hi\r\n\r\nbody"))
|
||||
if h.InReplyTo != "" || len(h.References) != 0 {
|
||||
t.Errorf("a first message claims to answer %q %v", h.InReplyTo, h.References)
|
||||
}
|
||||
}
|
||||
|
||||
// A chain can run to hundreds of ids. The ones nearest the end name the parent.
|
||||
func TestAVeryLongChainKeepsTheNewestIDs(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("References:")
|
||||
for i := 0; i < 50; i++ {
|
||||
fmt.Fprintf(&b, " <m%d@x.test>", i)
|
||||
}
|
||||
h := ExtractMessageHeaders([]byte(b.String() + "\r\nSubject: x\r\n\r\nbody"))
|
||||
if len(h.References) != maxReferences || h.References[len(h.References)-1] != "m49@x.test" {
|
||||
t.Errorf("kept %d ids ending %q", len(h.References), h.References[len(h.References)-1])
|
||||
}
|
||||
}
|
||||
@@ -347,6 +347,8 @@ func (uc *ProcessInboundEmailUseCase) Handle(ctx context.Context, input ProcessI
|
||||
FromDisplayName: headers.FromDisplayName,
|
||||
MessageIDHeader: headers.MessageID,
|
||||
HasAttachments: headers.HasAttachments,
|
||||
InReplyTo: headers.InReplyTo,
|
||||
References: headers.References,
|
||||
AlreadySeen: alreadySeen,
|
||||
SPFResult: authResult.SPF,
|
||||
DKIMResult: authResult.DKIM,
|
||||
|
||||
@@ -187,6 +187,8 @@ func (uc *ProcessOutboundEmailUseCase) fileSentCopy(ctx context.Context, input P
|
||||
FromDisplayName: headers.FromDisplayName,
|
||||
MessageIDHeader: headers.MessageID,
|
||||
HasAttachments: headers.HasAttachments,
|
||||
InReplyTo: headers.InReplyTo,
|
||||
References: headers.References,
|
||||
// The sender's own copy is not unread mail
|
||||
AlreadySeen: true,
|
||||
}})
|
||||
|
||||
@@ -37,6 +37,9 @@ type WebmailUseCase struct {
|
||||
// the submission use case, not here.)
|
||||
blobStore port.BlobStorage
|
||||
messages port.InboundMessageRepository
|
||||
// conversations serves the conversation views and records which message a
|
||||
// reply answered. Optional; see SetConversations.
|
||||
conversations port.ConversationReader
|
||||
}
|
||||
|
||||
func NewWebmailUseCase(
|
||||
@@ -91,6 +94,42 @@ func (uc *WebmailUseCase) ListMessages(
|
||||
return uc.repo.ListMessages(ctx, id, folder, search, limit, offset)
|
||||
}
|
||||
|
||||
// SetConversations enables the conversation views. Optional: without it the
|
||||
// thread routes answer "not configured" and the webmail falls back to the
|
||||
// plain message list.
|
||||
func (uc *WebmailUseCase) SetConversations(c port.ConversationReader) {
|
||||
uc.conversations = c
|
||||
}
|
||||
|
||||
// ErrConversationsDisabled is returned when no conversation reader was wired.
|
||||
var ErrConversationsDisabled = errors.New("conversations are not enabled")
|
||||
|
||||
func (uc *WebmailUseCase) ListThreads(
|
||||
ctx context.Context, address, folder, search string, limit, offset int,
|
||||
) ([]port.WebmailThread, error) {
|
||||
if uc.conversations == nil {
|
||||
return nil, ErrConversationsDisabled
|
||||
}
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.conversations.ListThreads(ctx, id, folder, search, limit, offset)
|
||||
}
|
||||
|
||||
func (uc *WebmailUseCase) ThreadMessages(
|
||||
ctx context.Context, address, folder, threadID string,
|
||||
) ([]port.WebmailThreadMessage, error) {
|
||||
if uc.conversations == nil {
|
||||
return nil, ErrConversationsDisabled
|
||||
}
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.conversations.ThreadMessages(ctx, id, folder, threadID)
|
||||
}
|
||||
|
||||
// GetMessage returns the raw RFC 5322 bytes and marks the message read.
|
||||
func (uc *WebmailUseCase) GetMessage(ctx context.Context, address, folder string, uid uint32) ([]byte, error) {
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
@@ -207,6 +246,16 @@ func (uc *WebmailUseCase) Send(ctx context.Context, input ComposeInput) error {
|
||||
// thing. Swallowed like the Sent copy above and for the same reason: the
|
||||
// mail is already queued, and failing the request now would only invite a
|
||||
// second copy to be sent.
|
||||
// The message this one answers is marked answered. Swallowed for the same
|
||||
// reason: the reply is already on its way, and a failed flag only means the
|
||||
// list shows the conversation as unanswered until the next message.
|
||||
if uc.conversations != nil && cleanMessageID(input.InReplyTo) != "" {
|
||||
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
|
||||
if err := uc.conversations.MarkAnsweredByMessageID(ctx, mailboxID, input.InReplyTo); err != nil {
|
||||
log.Printf("webmail: could not mark %s answered: %v", input.InReplyTo, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if input.DraftUID != 0 {
|
||||
if mailboxID, idErr := uc.mailboxID(ctx, account.EmailAddress); idErr == nil {
|
||||
if err := uc.repo.Expunge(ctx, mailboxID, "Drafts", input.DraftUID); err != nil {
|
||||
@@ -341,6 +390,8 @@ func (uc *WebmailUseCase) SaveDraft(ctx context.Context, input ComposeInput, rep
|
||||
FromDisplayName: headers.FromDisplayName,
|
||||
MessageIDHeader: headers.MessageID,
|
||||
HasAttachments: headers.HasAttachments,
|
||||
InReplyTo: headers.InReplyTo,
|
||||
References: headers.References,
|
||||
AlreadySeen: true,
|
||||
}})
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// Conversations reads the mailbox as conversations rather than as single
|
||||
// messages. Writing is MailStore's job, as for every other change.
|
||||
type Conversations struct {
|
||||
pool *pgxpool.Pool
|
||||
store *MailStore
|
||||
}
|
||||
|
||||
func NewConversations(pool *pgxpool.Pool) *Conversations {
|
||||
return &Conversations{pool: pool, store: NewMailStore(pool)}
|
||||
}
|
||||
|
||||
// scopeRow is one message that counts towards a conversation.
|
||||
type scopeRow struct {
|
||||
threadID uuid.UUID
|
||||
uid uint32
|
||||
folder string
|
||||
folderID string
|
||||
subject string
|
||||
sender string
|
||||
name string
|
||||
snippet string
|
||||
receivedAt time.Time
|
||||
hasAttach bool
|
||||
seen bool
|
||||
flagged bool
|
||||
answeredFlag bool
|
||||
own bool
|
||||
}
|
||||
|
||||
// scopeSQL selects the messages of the given conversations that belong in a
|
||||
// view of one folder: that folder's own messages, and the rest of the
|
||||
// conversation wherever it is filed, except what has been thrown away. Trash,
|
||||
// Junk and Drafts are left out of a conversation seen from elsewhere - a
|
||||
// reply still being written, or one deleted, is not part of the thread a reader
|
||||
// is looking at - and are the whole story when that is the folder viewed.
|
||||
const scopeSQL = `
|
||||
SELECT COALESCE(e.thread_id, e.id), e.uid, fo.name, fo.id::text,
|
||||
COALESCE(e.subject, ''), e.sender_address::text,
|
||||
COALESCE(e.from_display_name, ''), COALESCE(e.snippet, ''),
|
||||
e.received_at, e.has_attachments,
|
||||
EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Seen'),
|
||||
EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Flagged'),
|
||||
EXISTS (SELECT 1 FROM message_flags f WHERE f.message_id = e.id AND f.received_at = e.received_at AND f.flag = '\Answered'),
|
||||
e.sender_address::text = mb.email_address::text
|
||||
FROM email_messages e
|
||||
JOIN folders fo ON fo.id = e.folder_id
|
||||
JOIN mailboxes mb ON mb.id = e.mailbox_id
|
||||
WHERE e.mailbox_id = $1
|
||||
AND e.expunged_at IS NULL
|
||||
AND COALESCE(e.thread_id, e.id) = ANY($3::uuid[])
|
||||
AND (fo.id::text = $2
|
||||
OR (COALESCE(fo.special_use, '') NOT IN ('trash', 'junk', 'drafts')
|
||||
AND lower(fo.name) NOT IN ('trash', 'junk', 'drafts')))
|
||||
ORDER BY e.received_at ASC, e.uid ASC`
|
||||
|
||||
func (c *Conversations) scope(ctx context.Context, mailboxID, folderID string, threads []uuid.UUID) (map[uuid.UUID][]scopeRow, error) {
|
||||
rows, err := c.pool.Query(ctx, scopeSQL, mailboxID, folderID, threads)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the conversations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[uuid.UUID][]scopeRow, len(threads))
|
||||
for rows.Next() {
|
||||
var r scopeRow
|
||||
var uid int64
|
||||
if err := rows.Scan(&r.threadID, &uid, &r.folder, &r.folderID, &r.subject, &r.sender, &r.name,
|
||||
&r.snippet, &r.receivedAt, &r.hasAttach, &r.seen, &r.flagged, &r.answeredFlag, &r.own); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.uid = uint32(uid)
|
||||
out[r.threadID] = append(out[r.threadID], r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (c *Conversations) ListThreads(
|
||||
ctx context.Context, mailboxID, folderName, search string, limit, offset int,
|
||||
) ([]port.WebmailThread, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
folderID, err := c.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||
if errors.Is(err, port.ErrFolderMissing) {
|
||||
return []port.WebmailThread{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The page is chosen first, by the newest message each conversation has in
|
||||
// this folder, and only then are those conversations read in full.
|
||||
rows, err := c.pool.Query(ctx, `
|
||||
SELECT COALESCE(thread_id, id)
|
||||
FROM email_messages
|
||||
WHERE folder_id = $1 AND expunged_at IS NULL
|
||||
AND ($2 = '' OR subject ILIKE '%' || $2 || '%'
|
||||
OR sender_address::text ILIKE '%' || $2 || '%'
|
||||
OR snippet ILIKE '%' || $2 || '%')
|
||||
GROUP BY COALESCE(thread_id, id)
|
||||
ORDER BY max(received_at) DESC, max(uid) DESC
|
||||
LIMIT $3 OFFSET $4`, folderID, search, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list conversations: %w", err)
|
||||
}
|
||||
var page []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
page = append(page, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(page) == 0 {
|
||||
return []port.WebmailThread{}, nil
|
||||
}
|
||||
|
||||
byThread, err := c.scope(ctx, mailboxID, folderID, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]port.WebmailThread, 0, len(page))
|
||||
for _, id := range page {
|
||||
if t, ok := summarise(id, byThread[id], folderID); ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// summarise folds a conversation's messages into its list row. It reports
|
||||
// false for a conversation with nothing in the folder viewed.
|
||||
func summarise(id uuid.UUID, msgs []scopeRow, folderID string) (port.WebmailThread, bool) {
|
||||
var latestHere *scopeRow
|
||||
for i := range msgs {
|
||||
if msgs[i].folderID == folderID {
|
||||
latestHere = &msgs[i] // oldest first, so the last one wins
|
||||
}
|
||||
}
|
||||
if latestHere == nil {
|
||||
return port.WebmailThread{}, false
|
||||
}
|
||||
|
||||
t := port.WebmailThread{
|
||||
ThreadID: id.String(), UID: latestHere.uid, Subject: latestHere.subject,
|
||||
SenderAddress: latestHere.sender, FromName: latestHere.name, Snippet: latestHere.snippet,
|
||||
ReceivedAt: latestHere.receivedAt, MessageCount: len(msgs),
|
||||
Answered: answered(msgs),
|
||||
Participants: participants(msgs),
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if !m.seen {
|
||||
t.UnseenCount++
|
||||
}
|
||||
t.Flagged = t.Flagged || m.flagged
|
||||
t.HasAttachments = t.HasAttachments || m.hasAttach
|
||||
}
|
||||
t.Seen = t.UnseenCount == 0
|
||||
return t, true
|
||||
}
|
||||
|
||||
// answered reports whether the last thing someone else wrote has been replied
|
||||
// to - by the flag, or by a message of the owner's that came after it.
|
||||
func answered(msgs []scopeRow) bool {
|
||||
lastIncoming := -1
|
||||
for i, m := range msgs {
|
||||
if !m.own {
|
||||
lastIncoming = i
|
||||
}
|
||||
}
|
||||
if lastIncoming < 0 {
|
||||
return false
|
||||
}
|
||||
if msgs[lastIncoming].answeredFlag {
|
||||
return true
|
||||
}
|
||||
for _, m := range msgs[lastIncoming+1:] {
|
||||
if m.own {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// participants lists who wrote, in the order they first did, at most four.
|
||||
func participants(msgs []scopeRow) []port.WebmailParticipant {
|
||||
seen := map[string]bool{}
|
||||
var out []port.WebmailParticipant
|
||||
for _, m := range msgs {
|
||||
key := strings.ToLower(m.sender)
|
||||
if seen[key] || len(out) == 4 {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := m.name
|
||||
if name == "" {
|
||||
name = m.sender
|
||||
}
|
||||
out = append(out, port.WebmailParticipant{Name: name, Address: m.sender, Me: m.own})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Conversations) ThreadMessages(
|
||||
ctx context.Context, mailboxID, folderName, threadID string,
|
||||
) ([]port.WebmailThreadMessage, error) {
|
||||
id, err := uuid.Parse(threadID)
|
||||
if err != nil {
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
folderID, err := c.store.ResolveFolder(ctx, mailboxID, folderName)
|
||||
if errors.Is(err, port.ErrFolderMissing) {
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byThread, err := c.scope(ctx, mailboxID, folderID, []uuid.UUID{id})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgs := byThread[id]
|
||||
if len(msgs) == 0 {
|
||||
// Another mailbox's conversation and one that does not exist look alike.
|
||||
return nil, ErrMessageNotFound
|
||||
}
|
||||
|
||||
out := make([]port.WebmailThreadMessage, 0, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out = append(out, port.WebmailThreadMessage{
|
||||
UID: m.uid, Folder: m.folder, Subject: m.subject, SenderAddress: m.sender, FromName: m.name,
|
||||
Snippet: m.snippet, ReceivedAt: m.receivedAt, HasAttachments: m.hasAttach,
|
||||
Seen: m.seen, Flagged: m.flagged, Own: m.own,
|
||||
Answered: !m.own && (m.answeredFlag || ownAfter(msgs, i)),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(out, func(a, b int) bool { return out[a].ReceivedAt.Before(out[b].ReceivedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ownAfter(msgs []scopeRow, i int) bool {
|
||||
for _, m := range msgs[i+1:] {
|
||||
if m.own {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkAnsweredByMessageID sets \Answered on the message a reply was written to.
|
||||
func (c *Conversations) MarkAnsweredByMessageID(ctx context.Context, mailboxID, messageID string) error {
|
||||
messageID = strings.Trim(strings.TrimSpace(messageID), "<>")
|
||||
if messageID == "" {
|
||||
return nil
|
||||
}
|
||||
rows, err := c.pool.Query(ctx, `
|
||||
SELECT fo.id::text, e.uid
|
||||
FROM email_messages e JOIN folders fo ON fo.id = e.folder_id
|
||||
WHERE e.mailbox_id = $1 AND e.message_id_header = $2 AND e.expunged_at IS NULL
|
||||
AND COALESCE(fo.special_use, '') NOT IN ('sent', 'drafts', 'trash')`, mailboxID, messageID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find the message that was answered: %w", err)
|
||||
}
|
||||
type target struct {
|
||||
folder string
|
||||
uid int64
|
||||
}
|
||||
var targets []target
|
||||
for rows.Next() {
|
||||
var t target
|
||||
if err := rows.Scan(&t.folder, &t.uid); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
rows.Close()
|
||||
for _, t := range targets {
|
||||
if err := func() error {
|
||||
_, e := c.store.SetFlags(ctx, t.folder, uint32(t.uid), port.FlagOpAdd, []string{`\Answered`}, 0)
|
||||
return e
|
||||
}(); err != nil {
|
||||
return fmt.Errorf("flag the answered message: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -124,6 +124,11 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
||||
// id and received_at come back because message_flags is keyed on both:
|
||||
// email_messages is partitioned by received_at, so the flag rows cannot be
|
||||
// written without the same timestamp the row landed on.
|
||||
threadID, err := resolveThread(ctx, tx, input)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var messageID uuid.UUID
|
||||
var receivedAt time.Time
|
||||
err = tx.QueryRow(ctx, `
|
||||
@@ -131,8 +136,8 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
||||
mailbox_id, folder_id, uid, modseq, blob_id,
|
||||
sender_address, recipient_addresses, size_bytes,
|
||||
subject, snippet, from_display_name, message_id_header, has_attachments,
|
||||
spf_result, dkim_result, dmarc_result
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
spf_result, dkim_result, dmarc_result, in_reply_to, thread_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
RETURNING id, received_at
|
||||
`, input.MailboxID, folderID, uid, modseq, input.Blob.ID,
|
||||
input.SenderAddress, []string{input.RecipientAddress}, input.Blob.SizeBytes,
|
||||
@@ -147,7 +152,8 @@ func persistOne(ctx context.Context, tx pgx.Tx, input port.PersistInboundMessage
|
||||
// Sent copy and drafts have no authentication results by definition -
|
||||
// and it failed the whole insert. Sent stayed empty because that write
|
||||
// is deliberately non-fatal, and saving a draft reported an error.
|
||||
nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult)).
|
||||
nullIfEmpty(input.SPFResult), nullIfEmpty(input.DKIMResult), nullIfEmpty(input.DMARCResult),
|
||||
nullIfEmpty(input.InReplyTo), threadID).
|
||||
Scan(&messageID, &receivedAt)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert email_messages: %w", err)
|
||||
|
||||
@@ -426,7 +426,7 @@ func (s *MailStore) copyOne(ctx context.Context, sourceFolderID string, sourceUI
|
||||
SELECT $2::uuid, $3::uuid, $4::bigint, $5::bigint, blob_id,
|
||||
sender_address, recipient_addresses, size_bytes,
|
||||
spf_result, dkim_result, dmarc_result,
|
||||
from_display_name, subject, snippet, message_id_header, in_reply_to, thread_id, has_attachments,
|
||||
from_display_name, subject, snippet, message_id_header, in_reply_to, COALESCE(thread_id, id), has_attachments,
|
||||
arc_result, dane_status, tls_version, spam_score, spam_verdict, virus_name
|
||||
FROM email_messages WHERE id = $1 AND received_at = $6
|
||||
RETURNING id, received_at`,
|
||||
|
||||
@@ -102,6 +102,47 @@ func (m *mailAPI) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, messages)
|
||||
}
|
||||
|
||||
// handleThreads lists the folder as conversations, one row each.
|
||||
func (m *mailAPI) handleThreads(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := m.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
folder := query.Get("folder")
|
||||
if folder == "" {
|
||||
folder = "inbox"
|
||||
}
|
||||
limit, _ := strconv.Atoi(query.Get("limit"))
|
||||
offset, _ := strconv.Atoi(query.Get("offset"))
|
||||
|
||||
threads, err := m.useCase.ListThreads(r.Context(), session.Email, folder, query.Get("q"), limit, offset)
|
||||
if err != nil {
|
||||
m.fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, threads)
|
||||
}
|
||||
|
||||
// handleThread returns the messages of one conversation, oldest first.
|
||||
func (m *mailAPI) handleThread(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := m.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
threadID := strings.TrimPrefix(r.URL.Path, "/api/v1/mail/thread/")
|
||||
folder := r.URL.Query().Get("folder")
|
||||
if folder == "" {
|
||||
folder = "inbox"
|
||||
}
|
||||
messages, err := m.useCase.ThreadMessages(r.Context(), session.Email, folder, threadID)
|
||||
if err != nil {
|
||||
m.fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, messages)
|
||||
}
|
||||
|
||||
func (m *mailAPI) handleMessage(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := m.authenticate(w, r)
|
||||
if !ok {
|
||||
@@ -482,6 +523,8 @@ func (m *mailAPI) decodeCompose(
|
||||
// fail maps domain errors onto status codes without leaking internals.
|
||||
func (m *mailAPI) fail(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, usecase.ErrConversationsDisabled):
|
||||
writeError(w, http.StatusNotImplemented, "CONVERSATIONS_DISABLED", "Conversations are not enabled")
|
||||
case errors.Is(err, postgres.ErrMessageNotFound), errors.Is(err, usecase.ErrNoSuchMailbox):
|
||||
writeError(w, http.StatusNotFound, "NOT_FOUND", "Message not found")
|
||||
case errors.Is(err, usecase.ErrNoRecipients):
|
||||
|
||||
@@ -98,6 +98,8 @@ func (r *Router) registerRoutes() {
|
||||
r.mux.HandleFunc("/api/v1/mail/folders", r.handleMailFolders)
|
||||
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
|
||||
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
|
||||
r.mux.HandleFunc("/api/v1/mail/threads", r.handleMailThreads)
|
||||
r.mux.HandleFunc("/api/v1/mail/thread/", r.handleMailThread)
|
||||
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
|
||||
r.mux.HandleFunc("/api/v1/mail/flagged", r.handleMailFlagged)
|
||||
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
|
||||
@@ -287,6 +289,18 @@ func (r *Router) handleMailFolders(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleMailThreads(w http.ResponseWriter, req *http.Request) {
|
||||
if r.mailReady(w) {
|
||||
r.mail.handleThreads(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleMailThread(w http.ResponseWriter, req *http.Request) {
|
||||
if r.mailReady(w) {
|
||||
r.mail.handleThread(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleMailMessages(w http.ResponseWriter, req *http.Request) {
|
||||
if r.mailReady(w) {
|
||||
r.mail.handleMessages(w, req)
|
||||
|
||||
Reference in new issue
Block a user