Detailed changes
@@ -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
@@ -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.
@@ -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,
})
}
@@ -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)
@@ -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
@@ -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
@@ -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().