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 }