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>
311 lines
9.2 KiB
Go
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
|
|
}
|