From f381d11b083ef85ca48b209a4e41004ac05e54c0 Mon Sep 17 00:00:00 2001 From: Marwan051 <115355087+Marwan051@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:59:48 +0200 Subject: [PATCH] feat: add read and unread styles (#334) Co-authored-by: drew --- .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(-) diff --git a/.github/workflows/auto-enable-auto-merge.yml b/.github/workflows/auto-enable-auto-merge.yml index a81a8b87c18c9d84645465255653d2686cac0287..ca4d8240d0f6d027e36dbb59e3ea1e7c50c40f9b 100644 --- a/.github/workflows/auto-enable-auto-merge.yml +++ b/.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 diff --git a/config/cache.go b/config/cache.go index ad211d0ecdf84cada5efcb21ab4d1e81c1abc2d7..d26ff0d38c315ed0ad9b8d27fc54ade745ae7691 100644 --- a/config/cache.go +++ b/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. diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 81f0476a9d69689a24badecf3dded65a37b4e4be..ad269f41a857c04af1b2af4e2a0810a6e10c9d8d 100644 --- a/fetcher/fetcher.go +++ b/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, }) } diff --git a/main.go b/main.go index 9b01a6b9fbc05b7842cfa8b0485c7ddbae7a90d3..086d8164f37bd4d1fcdca4519451a2770e4cf249 100644 --- a/main.go +++ b/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) diff --git a/tui/inbox.go b/tui/inbox.go index 9d123d6aa1f92030eedcbe89e3203418a9b0c3b8..71acb3a9fc1aadbbcb9188feff5926341e7f2ada 100644 --- a/tui/inbox.go +++ b/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 diff --git a/tui/messages.go b/tui/messages.go index 94d3da3af0b99c8531354e398f5a14c1c00e1f67..c05aee344cc7fa155333c4f2117600ed3f4107c7 100644 --- a/tui/messages.go +++ b/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 diff --git a/tui/theme.go b/tui/theme.go index 904e5b0d73dcada9b504370986d223072f7eb855..a9365c4ebdffa177e4de5a82799f512c46926234 100644 --- a/tui/theme.go +++ b/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().