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,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
|
||||
}
|
||||
Reference in new issue
Block a user