Files
LambdaMail/services/protocols/internal/infrastructure/postgres/conversations.go
T
Lucas RayanandClaude Sonnet 5.5 67d0c8aa5a 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>
2026-10-22 16:38:36 -03:00

311 lines
9.2 KiB
Go

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
}