feat: add read and unread styles (#334)

Marwan051 and drew created

Co-authored-by: drew <me@andrinoff.com>

Change summary

.github/workflows/auto-enable-auto-merge.yml |  5 +
config/cache.go                              |  1 
fetcher/fetcher.go                           | 32 +++++++++
main.go                                      | 69 +++++++++++++++++++++
tui/inbox.go                                 | 44 ++++++++++++-
tui/messages.go                              | 32 +++++++--
tui/theme.go                                 |  3 
7 files changed, 169 insertions(+), 17 deletions(-)

Detailed changes

.github/workflows/auto-enable-auto-merge.yml ๐Ÿ”—

@@ -11,6 +11,11 @@ permissions:
 
 jobs:
   enable-auto-merge:
+    # When a fork PR is opened, both pull_request_target and pull_request fire.
+    # Skip the pull_request run for fork PRs to avoid a duplicate (failing) job.
+    if: >-
+      github.event_name == 'pull_request_target' ||
+      (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
     runs-on: ubuntu-latest
     steps:
       - name: Enable Auto-Merge

config/cache.go ๐Ÿ”—

@@ -18,6 +18,7 @@ type CachedEmail struct {
 	Date      time.Time `json:"date"`
 	MessageID string    `json:"message_id"`
 	AccountID string    `json:"account_id"`
+	IsRead    bool      `json:"is_read"`
 }
 
 // EmailCache stores cached emails for all accounts.

fetcher/fetcher.go ๐Ÿ”—

@@ -13,6 +13,7 @@ import (
 	"mime"
 	"mime/quotedprintable"
 	"os"
+	"slices"
 	"strings"
 	"time"
 
@@ -46,6 +47,7 @@ type Email struct {
 	Subject     string
 	Body        string
 	Date        time.Time
+	IsRead      bool
 	MessageID   string
 	References  []string
 	Attachments []Attachment
@@ -69,6 +71,10 @@ func formatAddress(addr *imap.Address) string {
 	return email
 }
 
+func hasSeenFlag(flags []string) bool {
+	return slices.Contains(flags, imap.SeenFlag)
+}
+
 func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
 	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
 	if err != nil {
@@ -261,7 +267,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 
 		messages := make(chan *imap.Message, chunkSize)
 		done := make(chan error, 1)
-		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
+		fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
 
 		go func() {
 			done <- c.Fetch(seqset, fetchItems, messages)
@@ -324,6 +330,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 				To:        toAddrList,
 				Subject:   decodeHeader(msg.Envelope.Subject),
 				Date:      msg.Envelope.Date,
+				IsRead:    hasSeenFlag(msg.Flags),
 				AccountID: account.ID,
 			})
 		}
@@ -887,6 +894,26 @@ func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox s
 	return c.UidMove(seqSet, destMailbox)
 }
 
+func MarkEmailAsReadInMailbox(account *config.Account, mailbox string, uid uint32) error {
+	c, err := connect(account)
+	if err != nil {
+		return err
+	}
+	defer c.Logout()
+
+	if _, err := c.Select(mailbox, false); err != nil {
+		return err
+	}
+
+	seqSet := new(imap.SeqSet)
+	seqSet.AddNum(uid)
+
+	item := imap.FormatFlagsOp(imap.AddFlags, true)
+	flags := []interface{}{imap.SeenFlag}
+
+	return c.UidStore(seqSet, item, flags, nil)
+}
+
 func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error {
 	c, err := connect(account)
 	if err != nil {
@@ -1065,7 +1092,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
 
 	messages := make(chan *imap.Message, limit)
 	done := make(chan error, 1)
-	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}
+	fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchFlags}
 	go func() {
 		done <- c.Fetch(seqset, fetchItems, messages)
 	}()
@@ -1130,6 +1157,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email,
 			To:        toAddrList,
 			Subject:   decodeHeader(msg.Envelope.Subject),
 			Date:      msg.Envelope.Date,
+			IsRead:    hasSeenFlag(msg.Flags),
 			AccountID: account.ID,
 		})
 	}

main.go ๐Ÿ”—

@@ -678,11 +678,30 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, nil
 		}
 
+		// Mark as read in UI immediately and on the server
+		var markReadCmd tea.Cmd
+		if !email.IsRead {
+			m.markEmailAsReadInStores(msg.UID, msg.AccountID)
+
+			folderName := "INBOX"
+			if m.folderInbox != nil {
+				folderName = m.folderInbox.GetCurrentFolder()
+			}
+			account := m.config.GetAccountByID(msg.AccountID)
+			if account != nil {
+				markReadCmd = markEmailAsReadCmd(account, msg.UID, msg.AccountID, folderName)
+			}
+		}
+
 		// Find the index for the email view (used for display purposes)
 		emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox)
 		emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox, m.config.DisableImages)
 		m.current = emailView
-		return m, m.current.Init()
+		cmds := []tea.Cmd{m.current.Init()}
+		if markReadCmd != nil {
+			cmds = append(cmds, markReadCmd)
+		}
+		return m, tea.Batch(cmds...)
 
 	case tui.ReplyToEmailMsg:
 		to := msg.Email.From
@@ -864,6 +883,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		return m, tea.Batch(m.current.Init(), archiveFolderEmailCmd(account, msg.UID, msg.AccountID, folderName, msg.Mailbox))
 
+	case tui.EmailMarkedReadMsg:
+		if msg.Err != nil {
+			log.Printf("Error marking email as read: %v", msg.Err)
+		}
+		return m, nil
+
 	case tui.EmailActionDoneMsg:
 		if msg.Err != nil {
 			log.Printf("Action failed: %v", msg.Err)
@@ -997,6 +1022,38 @@ func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox t
 	}
 }
 
+func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) {
+	for i := range m.emails {
+		if m.emails[i].UID == uid && m.emails[i].AccountID == accountID {
+			m.emails[i].IsRead = true
+			break
+		}
+	}
+	if emails, ok := m.emailsByAcct[accountID]; ok {
+		for i := range emails {
+			if emails[i].UID == uid {
+				emails[i].IsRead = true
+				break
+			}
+		}
+	}
+	// Update folder email cache
+	for folderName, folderEmails := range m.folderEmails {
+		for i := range folderEmails {
+			if folderEmails[i].UID == uid && folderEmails[i].AccountID == accountID {
+				folderEmails[i].IsRead = true
+				m.folderEmails[folderName] = folderEmails
+				go saveFolderEmailsToCache(folderName, folderEmails)
+				break
+			}
+		}
+	}
+	// Update the inbox UI
+	if m.folderInbox != nil {
+		m.folderInbox.GetInbox().MarkEmailAsRead(uid, accountID)
+	}
+}
+
 func (m *mainModel) removeEmailFromStores(uid uint32, accountID string) {
 	var filtered []fetcher.Email
 	for _, e := range m.emails {
@@ -1172,6 +1229,7 @@ func emailsToCache(emails []fetcher.Email) []config.CachedEmail {
 			Date:      email.Date,
 			MessageID: email.MessageID,
 			AccountID: email.AccountID,
+			IsRead:    email.IsRead,
 		})
 	}
 	return cached
@@ -1188,6 +1246,7 @@ func cacheToEmails(cached []config.CachedEmail) []fetcher.Email {
 			Date:      c.Date,
 			MessageID: c.MessageID,
 			AccountID: c.AccountID,
+			IsRead:    c.IsRead,
 		})
 	}
 	return emails
@@ -1222,6 +1281,7 @@ func saveEmailsToCache(emails []fetcher.Email) {
 			Date:      email.Date,
 			MessageID: email.MessageID,
 			AccountID: email.AccountID,
+			IsRead:    email.IsRead,
 		})
 
 		// Save sender as a contact
@@ -1524,6 +1584,13 @@ func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, f
 	}
 }
 
+func markEmailAsReadCmd(account *config.Account, uid uint32, accountID string, folderName string) tea.Cmd {
+	return func() tea.Msg {
+		err := fetcher.MarkEmailAsReadInMailbox(account, folderName, uid)
+		return tui.EmailMarkedReadMsg{UID: uid, AccountID: accountID, Err: err}
+	}
+}
+
 func deleteFolderEmailCmd(account *config.Account, uid uint32, accountID string, folderName string, mailbox tui.MailboxKind) tea.Cmd {
 	return func() tea.Msg {
 		err := fetcher.DeleteFolderEmail(account, folderName, uid)

tui/inbox.go ๐Ÿ”—

@@ -25,7 +25,8 @@ var (
 )
 
 var dateStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
-var senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Bold(true)
+var unreadEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
+var readEmailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 
 type item struct {
 	title, desc   string
@@ -34,6 +35,7 @@ type item struct {
 	accountID     string
 	accountEmail  string
 	date          time.Time
+	isRead        bool
 }
 
 func (i item) Title() string       { return i.title }
@@ -53,7 +55,14 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 
 	prefix := fmt.Sprintf("%d. ", index+1)
 	sender := parseSenderName(i.desc)
-	styledSender := senderStyle.Render(sender)
+	statusStyle := unreadEmailStyle
+	statusIcon := "\uf0e0"
+	if i.isRead {
+		statusStyle = readEmailStyle
+		statusIcon = "\uf2b6"
+	}
+	styledIcon := statusStyle.Render(statusIcon)
+	styledSender := statusStyle.Render(sender)
 	separator := " ยท "
 
 	// For "ALL" view, show account indicator instead of number
@@ -64,6 +73,11 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	// Format and right-align date
 	dateStr := formatRelativeDate(i.date)
 	styledDate := dateStyle.Render(dateStr)
+	if i.isRead {
+		styledDate = readEmailStyle.Render(dateStr)
+	} else {
+		styledDate = statusStyle.Render(dateStr)
+	}
 	dateWidth := lipgloss.Width(styledDate)
 
 	listWidth := m.Width()
@@ -80,9 +94,10 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	}
 
 	prefixWidth := lipgloss.Width(prefix)
+	iconWidth := lipgloss.Width(styledIcon) + 1
 	senderWidth := lipgloss.Width(styledSender)
 	sepWidth := len(separator)
-	subjectBudget := maxLeft - prefixWidth - senderWidth - sepWidth
+	subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth
 
 	subject := i.title
 	if subjectBudget < 4 {
@@ -94,8 +109,9 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 		}
 		subject += "โ€ฆ"
 	}
+	styledSubject := statusStyle.Render(subject)
 
-	str := prefix + styledSender + separator + subject
+	str := prefix + styledIcon + " " + styledSender + separator + styledSubject
 
 	// Pad to push date to the right
 	padding := listWidth - lipgloss.Width(str) - dateWidth - cursorWidth
@@ -308,6 +324,7 @@ func (m *Inbox) updateList() {
 			accountID:     email.AccountID,
 			accountEmail:  accountEmail,
 			date:          email.Date,
+			isRead:        email.IsRead,
 		}
 	}
 
@@ -686,6 +703,25 @@ func (m *Inbox) GetMailbox() MailboxKind {
 	return m.mailbox
 }
 
+// MarkEmailAsRead marks an email as read by UID and account ID, updating it in all stores.
+func (m *Inbox) MarkEmailAsRead(uid uint32, accountID string) {
+	for i := range m.allEmails {
+		if m.allEmails[i].UID == uid && m.allEmails[i].AccountID == accountID {
+			m.allEmails[i].IsRead = true
+			break
+		}
+	}
+	if emails, ok := m.emailsByAccount[accountID]; ok {
+		for i := range emails {
+			if emails[i].UID == uid {
+				emails[i].IsRead = true
+				break
+			}
+		}
+	}
+	m.updateList()
+}
+
 // RemoveEmail removes an email by UID and account ID
 func (m *Inbox) RemoveEmail(uid uint32, accountID string) {
 	// Remove from account-specific list

tui/messages.go ๐Ÿ”—

@@ -186,15 +186,15 @@ type GoToAddMailingListMsg struct{}
 
 // GoToEditAccountMsg signals navigation to edit an existing account.
 type GoToEditAccountMsg struct {
-	AccountID      string
-	Provider       string
-	Name           string
-	Email          string
-	FetchEmail     string
-	IMAPServer     string
-	IMAPPort       int
-	SMTPServer     string
-	SMTPPort       int
+	AccountID  string
+	Provider   string
+	Name       string
+	Email      string
+	FetchEmail string
+	IMAPServer string
+	IMAPPort   int
+	SMTPServer string
+	SMTPPort   int
 }
 
 // GoToEditMailingListMsg signals navigation to edit an existing mailing list.
@@ -369,6 +369,20 @@ type EmailMovedMsg struct {
 	Err          error
 }
 
+// MarkEmailAsReadMsg signals that an email should be marked as read on the server.
+type MarkEmailAsReadMsg struct {
+	UID        uint32
+	AccountID  string
+	FolderName string
+}
+
+// EmailMarkedReadMsg signals that an email was marked as read.
+type EmailMarkedReadMsg struct {
+	UID       uint32
+	AccountID string
+	Err       error
+}
+
 // FetchFolderMoreEmailsMsg signals a request to fetch more emails from a folder (pagination).
 type FetchFolderMoreEmailsMsg struct {
 	Offset     uint32

tui/theme.go ๐Ÿ”—

@@ -70,7 +70,8 @@ func RebuildStyles() {
 	activeTabStyle = lipgloss.NewStyle().Padding(0, 2).Foreground(t.Accent).Bold(true).Underline(true)
 	tabBarStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).PaddingBottom(1).MarginBottom(1)
 	dateStyle = lipgloss.NewStyle().Foreground(t.MutedText)
-	senderStyle = lipgloss.NewStyle().Foreground(t.DimText).Bold(true)
+	unreadEmailStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
+	readEmailStyle = lipgloss.NewStyle().Foreground(t.Secondary)
 
 	// folder_inbox.go
 	sidebarStyle = lipgloss.NewStyle().