From d02f320856129bf4a98f7ac30946ed394a353ac6 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Fri, 2 Jan 2026 23:03:34 +0400 Subject: [PATCH] feat: sent inbox (#64) (#69) --- fetcher/fetcher.go | 77 +++++-- go.mod | 7 +- main.go | 446 ++++++++++++++++++++++++++++------------- tui/choice.go | 4 +- tui/email_view.go | 11 +- tui/email_view_test.go | 13 +- tui/inbox.go | 48 ++++- tui/messages.go | 29 ++- 8 files changed, 465 insertions(+), 170 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index ed50c26f6ebc5b1edbfe6566a38ace74637e3b6c..3f45d9d143c1c4ea59174c14092f1019ffc6c5a3 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -107,14 +107,25 @@ func connect(account *config.Account) (*client.Client, error) { return c, nil } -func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) { +func getSentMailbox(account *config.Account) string { + switch account.ServiceProvider { + case "gmail": + return "[Gmail]/Sent Mail" + case "icloud": + return "Sent Messages" + default: + return "Sent" + } +} + +func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset uint32) ([]Email, error) { c, err := connect(account) if err != nil { return nil, err } defer c.Logout() - mbox, err := c.Select("INBOX", false) + mbox, err := c.Select(mailbox, false) if err != nil { return nil, err } @@ -209,14 +220,14 @@ func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) return emails, nil } -func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { +func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) { c, err := connect(account) if err != nil { return "", nil, err } defer c.Logout() - if _, err := c.Select("INBOX", false); err != nil { + if _, err := c.Select(mailbox, false); err != nil { return "", nil, err } @@ -374,14 +385,14 @@ func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, return body, attachments, nil } -func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { +func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) { c, err := connect(account) if err != nil { return nil, err } defer c.Logout() - if _, err := c.Select("INBOX", false); err != nil { + if _, err := c.Select(mailbox, false); err != nil { return nil, err } @@ -438,14 +449,14 @@ func FetchAttachment(account *config.Account, uid uint32, partID string, encodin } } -func moveEmail(account *config.Account, uid uint32, destMailbox string) error { +func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error { c, err := connect(account) if err != nil { return err } defer c.Logout() - if _, err := c.Select("INBOX", false); err != nil { + if _, err := c.Select(sourceMailbox, false); err != nil { return err } @@ -455,14 +466,14 @@ func moveEmail(account *config.Account, uid uint32, destMailbox string) error { return c.UidMove(seqSet, destMailbox) } -func DeleteEmail(account *config.Account, uid uint32) error { +func DeleteEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error { c, err := connect(account) if err != nil { return err } defer c.Logout() - if _, err := c.Select("INBOX", false); err != nil { + if _, err := c.Select(mailbox, false); err != nil { return err } @@ -479,7 +490,7 @@ func DeleteEmail(account *config.Account, uid uint32) error { return c.Expunge(nil) } -func ArchiveEmail(account *config.Account, uid uint32) error { +func ArchiveEmailFromMailbox(account *config.Account, mailbox string, uid uint32) error { var archiveMailbox string switch account.ServiceProvider { case "gmail": @@ -487,5 +498,47 @@ func ArchiveEmail(account *config.Account, uid uint32) error { default: archiveMailbox = "Archive" } - return moveEmail(account, uid, archiveMailbox) + return moveEmail(account, uid, mailbox, archiveMailbox) +} + +// Convenience wrappers defaulting to INBOX for existing call sites. + +func FetchEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + return FetchMailboxEmails(account, "INBOX", limit, offset) +} + +func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + return FetchMailboxEmails(account, getSentMailbox(account), limit, offset) +} + +func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + return FetchEmailBodyFromMailbox(account, "INBOX", uid) +} + +func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid) +} + +func FetchAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { + return FetchAttachmentFromMailbox(account, "INBOX", uid, partID, encoding) +} + +func FetchSentAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { + return FetchAttachmentFromMailbox(account, getSentMailbox(account), uid, partID, encoding) +} + +func DeleteEmail(account *config.Account, uid uint32) error { + return DeleteEmailFromMailbox(account, "INBOX", uid) +} + +func DeleteSentEmail(account *config.Account, uid uint32) error { + return DeleteEmailFromMailbox(account, getSentMailbox(account), uid) +} + +func ArchiveEmail(account *config.Account, uid uint32) error { + return ArchiveEmailFromMailbox(account, "INBOX", uid) +} + +func ArchiveSentEmail(account *config.Account, uid uint32) error { + return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid) } diff --git a/go.mod b/go.mod index f76140988741dc020e352386eb4e7dba7231cba2..17202eef7b46405bdc1c38f869c94721c2b483e7 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect @@ -34,7 +34,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/net v0.39.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect ) diff --git a/main.go b/main.go index 4cd9f5675824693a8591feb041d923e125e39e10..dfcc6d748cd107e818726bfd814bc999f8d97d67 100644 --- a/main.go +++ b/main.go @@ -63,7 +63,10 @@ type mainModel struct { config *config.Config emails []fetcher.Email emailsByAcct map[string][]fetcher.Email + sentEmails []fetcher.Email + sentByAcct map[string][]fetcher.Email inbox *tui.Inbox + sentInbox *tui.Inbox width int height int err error @@ -72,6 +75,7 @@ type mainModel struct { func newInitialModel(cfg *config.Config) *mainModel { initialModel := &mainModel{ emailsByAcct: make(map[string][]fetcher.Email), + sentByAcct: make(map[string][]fetcher.Email), } if cfg == nil || !cfg.HasAccounts() { @@ -122,6 +126,22 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case tui.BackToMailboxMsg: + switch msg.Mailbox { + case tui.MailboxSent: + if m.sentInbox != nil { + m.current = m.sentInbox + return m, nil + } + case tui.MailboxInbox: + if m.inbox != nil { + m.current = m.inbox + return m, nil + } + } + m.current = tui.NewChoice() + return m, nil + case tui.DiscardDraftMsg: // Save draft to disk if msg.ComposerState != nil { @@ -196,13 +216,21 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // No cache, fetch normally m.current = tui.NewStatus("Fetching emails from all accounts...") - return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config)) + return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxInbox)) + + case tui.GoToSentInboxMsg: + if m.config == nil || !m.config.HasAccounts() { + m.current = tui.NewLogin() + return m, m.current.Init() + } + m.current = tui.NewStatus("Fetching sent emails from all accounts...") + return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxSent)) case tui.CachedEmailsLoadedMsg: if msg.Cache == nil { // Cache load failed, fetch normally m.current = tui.NewStatus("Fetching emails from all accounts...") - return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config)) + return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxInbox)) } // Convert cached emails to fetcher.Email @@ -231,31 +259,25 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Start background refresh return m, tea.Batch( m.current.Init(), - func() tea.Msg { return tui.RefreshingEmailsMsg{} }, - refreshEmails(m.config), + func() tea.Msg { return tui.RefreshingEmailsMsg{Mailbox: tui.MailboxInbox} }, + refreshEmails(m.config, tui.MailboxInbox), ) case tui.EmailsRefreshedMsg: - m.emailsByAcct = msg.EmailsByAccount - - // Flatten all emails - var allEmails []fetcher.Email - for _, emails := range msg.EmailsByAccount { - allEmails = append(allEmails, emails...) - } - - // Sort by date (newest first) - for i := 0; i < len(allEmails); i++ { - for j := i + 1; j < len(allEmails); j++ { - if allEmails[j].Date.After(allEmails[i].Date) { - allEmails[i], allEmails[j] = allEmails[j], allEmails[i] - } + if msg.Mailbox == tui.MailboxSent { + m.sentByAcct = msg.EmailsByAccount + m.sentEmails = flattenAndSort(msg.EmailsByAccount) + if m.sentInbox != nil { + m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts) + m.current, _ = m.current.Update(msg) } + return m, nil } - m.emails = allEmails + m.emailsByAcct = msg.EmailsByAccount + m.emails = flattenAndSort(msg.EmailsByAccount) - // Save to cache + // Save to cache (inbox only) go saveEmailsToCache(m.emails) // Update inbox if it exists @@ -267,24 +289,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tui.AllEmailsFetchedMsg: - m.emailsByAcct = msg.EmailsByAccount - - // Flatten all emails - var allEmails []fetcher.Email - for _, emails := range msg.EmailsByAccount { - allEmails = append(allEmails, emails...) - } + if msg.Mailbox == tui.MailboxSent { + m.sentByAcct = msg.EmailsByAccount + m.sentEmails = flattenAndSort(msg.EmailsByAccount) - // Sort by date (newest first) - for i := 0; i < len(allEmails); i++ { - for j := i + 1; j < len(allEmails); j++ { - if allEmails[j].Date.After(allEmails[i].Date) { - allEmails[i], allEmails[j] = allEmails[j], allEmails[i] - } - } + m.sentInbox = tui.NewSentInbox(m.sentEmails, m.config.Accounts) + m.current = m.sentInbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() } - m.emails = allEmails + m.emailsByAcct = msg.EmailsByAccount + m.emails = flattenAndSort(msg.EmailsByAccount) // Save to cache go saveEmailsToCache(m.emails) @@ -295,28 +311,28 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() case tui.EmailsFetchedMsg: - // Single account fetch result + if msg.Mailbox == tui.MailboxSent { + if m.sentByAcct == nil { + m.sentByAcct = make(map[string][]fetcher.Email) + } + m.sentByAcct[msg.AccountID] = msg.Emails + m.sentEmails = flattenAndSort(m.sentByAcct) + if m.sentInbox == nil { + m.sentInbox = tui.NewSentInbox(m.sentEmails, m.config.Accounts) + } else { + m.sentInbox.SetEmails(m.sentEmails, m.config.Accounts) + } + m.current = m.sentInbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + + // Inbox path if m.emailsByAcct == nil { m.emailsByAcct = make(map[string][]fetcher.Email) } m.emailsByAcct[msg.AccountID] = msg.Emails - - // Rebuild all emails - var allEmails []fetcher.Email - for _, emails := range m.emailsByAcct { - allEmails = append(allEmails, emails...) - } - - // Sort by date - for i := 0; i < len(allEmails); i++ { - for j := i + 1; j < len(allEmails); j++ { - if allEmails[j].Date.After(allEmails[i].Date) { - allEmails[i], allEmails[j] = allEmails[j], allEmails[i] - } - } - } - - m.emails = allEmails + m.emails = flattenAndSort(m.emailsByAcct) if m.inbox == nil { m.inbox = tui.NewInbox(m.emails, m.config.Accounts) } else { @@ -336,11 +352,19 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch( func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }, - fetchEmails(account, paginationLimit, msg.Offset), + fetchEmails(account, paginationLimit, msg.Offset, msg.Mailbox), ) case tui.EmailsAppendedMsg: - // Add new emails to the appropriate account + if msg.Mailbox == tui.MailboxSent { + if m.sentByAcct == nil { + m.sentByAcct = make(map[string][]fetcher.Email) + } + m.sentByAcct[msg.AccountID] = append(m.sentByAcct[msg.AccountID], msg.Emails...) + m.sentEmails = append(m.sentEmails, msg.Emails...) + return m, nil + } + // Inbox if m.emailsByAcct == nil { m.emailsByAcct = make(map[string][]fetcher.Email) } @@ -426,32 +450,40 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() case tui.ViewEmailMsg: - email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID) + email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) if email == nil { return m, nil } m.current = tui.NewStatus("Fetching email content...") - return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID)) + return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, *email, msg.UID, msg.AccountID, msg.Mailbox)) case tui.EmailBodyFetchedMsg: if msg.Err != nil { log.Printf("could not fetch email body: %v", msg.Err) - m.current = m.inbox + if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { + m.current = m.sentInbox + } else { + m.current = m.inbox + } return m, nil } // Update the email in our stores - m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Body, msg.Attachments) + m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments) - email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID) + email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) if email == nil { - m.current = m.inbox + if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { + m.current = m.sentInbox + } else { + m.current = m.inbox + } return m, nil } // Find the index for the email view (used for display purposes) - emailIndex := m.getEmailIndex(msg.UID, msg.AccountID) - emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height) + emailIndex := m.getEmailIndex(msg.UID, msg.AccountID, msg.Mailbox) + emailView := tui.NewEmailView(*email, emailIndex, m.width, m.height, msg.Mailbox) m.current = emailView return m, m.current.Init() @@ -536,11 +568,15 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { account := m.config.GetAccountByID(msg.AccountID) if account == nil { - m.current = m.inbox + if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { + m.current = m.sentInbox + } else { + m.current = m.inbox + } return m, nil } - return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID)) + return m, tea.Batch(m.current.Init(), deleteEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox)) case tui.ArchiveEmailMsg: m.previousModel = m.current @@ -548,27 +584,48 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { account := m.config.GetAccountByID(msg.AccountID) if account == nil { - m.current = m.inbox + if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { + m.current = m.sentInbox + } else { + m.current = m.inbox + } return m, nil } - return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID)) + return m, tea.Batch(m.current.Init(), archiveEmailCmd(account, msg.UID, msg.AccountID, msg.Mailbox)) case tui.EmailActionDoneMsg: if msg.Err != nil { log.Printf("Action failed: %v", msg.Err) - m.current = m.inbox + if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { + m.current = m.sentInbox + } else { + m.current = m.inbox + } return m, nil } // Remove email from stores - m.removeEmail(msg.UID, msg.AccountID) + m.removeEmailByMailbox(msg.UID, msg.AccountID, msg.Mailbox) + + if msg.Mailbox == tui.MailboxSent { + if m.sentInbox != nil { + m.sentInbox.RemoveEmail(msg.UID, msg.AccountID) + m.current = m.sentInbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + m.current = tui.NewChoice() + return m, m.current.Init() + } if m.inbox != nil { m.inbox.RemoveEmail(msg.UID, msg.AccountID) + m.current = m.inbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() } - m.current = m.inbox - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + m.current = tui.NewChoice() return m, m.current.Init() case tui.DownloadAttachmentMsg: @@ -581,7 +638,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - email := m.getEmailByIndex(msg.Index) + email := m.getEmailByIndex(msg.Index, msg.Mailbox) if email == nil { m.current = m.previousModel return m, nil @@ -602,6 +659,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Data: msg.Data, AccountID: msg.AccountID, Encoding: encoding, + Mailbox: msg.Mailbox, } return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(account, email.UID, newMsg)) @@ -628,72 +686,131 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *mainModel) getEmailByIndex(index int) *fetcher.Email { - if index >= 0 && index < len(m.emails) { - return &m.emails[index] +func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher.Email { + switch mailbox { + case tui.MailboxSent: + if index >= 0 && index < len(m.sentEmails) { + return &m.sentEmails[index] + } + default: + if index >= 0 && index < len(m.emails) { + return &m.emails[index] + } } return nil } -func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string) *fetcher.Email { - for i := range m.emails { - if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { - return &m.emails[i] +func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbox tui.MailboxKind) *fetcher.Email { + switch mailbox { + case tui.MailboxSent: + for i := range m.sentEmails { + if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID { + return &m.sentEmails[i] + } + } + default: + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + return &m.emails[i] + } } } return nil } -func (m *mainModel) getEmailIndex(uid uint32, accountID string) int { - for i := range m.emails { - if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { - return i +func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.MailboxKind) int { + switch mailbox { + case tui.MailboxSent: + for i := range m.sentEmails { + if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID { + return i + } + } + default: + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + return i + } } } return -1 } -func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, body string, attachments []fetcher.Attachment) { - // Update in all emails list - for i := range m.emails { - if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { - m.emails[i].Body = body - m.emails[i].Attachments = attachments - break +func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) { + switch mailbox { + case tui.MailboxSent: + for i := range m.sentEmails { + if m.sentEmails[i].UID == uid && m.sentEmails[i].AccountID == accountID { + m.sentEmails[i].Body = body + m.sentEmails[i].Attachments = attachments + break + } } - } - - // Also update in account-specific store - if emails, ok := m.emailsByAcct[accountID]; ok { - for i := range emails { - if emails[i].UID == uid { - emails[i].Body = body - emails[i].Attachments = attachments + if emails, ok := m.sentByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } + default: + for i := range m.emails { + if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { + m.emails[i].Body = body + m.emails[i].Attachments = attachments break } } + if emails, ok := m.emailsByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } } } -func (m *mainModel) removeEmail(uid uint32, accountID string) { - // Remove from all emails - var filtered []fetcher.Email - for _, e := range m.emails { - if !(e.UID == uid && e.AccountID == accountID) { - filtered = append(filtered, e) +func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox tui.MailboxKind) { + switch mailbox { + case tui.MailboxSent: + var filtered []fetcher.Email + for _, e := range m.sentEmails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) + } } - } - m.emails = filtered - - // Remove from account-specific store - if emails, ok := m.emailsByAcct[accountID]; ok { - var filteredAcct []fetcher.Email - for _, e := range emails { - if e.UID != uid { - filteredAcct = append(filteredAcct, e) + m.sentEmails = filtered + if emails, ok := m.sentByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.sentByAcct[accountID] = filteredAcct + } + default: + var filtered []fetcher.Email + for _, e := range m.emails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) } } - m.emailsByAcct[accountID] = filteredAcct + m.emails = filtered + if emails, ok := m.emailsByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.emailsByAcct[accountID] = filteredAcct + } } } @@ -701,7 +818,22 @@ func (m *mainModel) View() string { return m.current.View() } -func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd { +func flattenAndSort(emailsByAccount map[string][]fetcher.Email) []fetcher.Email { + var allEmails []fetcher.Email + for _, emails := range emailsByAccount { + allEmails = append(allEmails, emails...) + } + for i := 0; i < len(allEmails); i++ { + for j := i + 1; j < len(allEmails); j++ { + if allEmails[j].Date.After(allEmails[i].Date) { + allEmails[i], allEmails[j] = allEmails[j], allEmails[i] + } + } + } + return allEmails +} + +func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { emailsByAccount := make(map[string][]fetcher.Email) var mu sync.Mutex @@ -711,7 +843,13 @@ func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd { wg.Add(1) go func(acc config.Account) { defer wg.Done() - emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0) + var emails []fetcher.Email + var err error + if mailbox == tui.MailboxSent { + emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0) + } else { + emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0) + } if err != nil { log.Printf("Error fetching from %s: %v", acc.Email, err) return @@ -723,20 +861,26 @@ func fetchAllAccountsEmails(cfg *config.Config) tea.Cmd { } wg.Wait() - return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount} + return tui.AllEmailsFetchedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox} } } -func fetchEmails(account *config.Account, limit, offset uint32) tea.Cmd { +func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { - emails, err := fetcher.FetchEmails(account, limit, offset) + var emails []fetcher.Email + var err error + if mailbox == tui.MailboxSent { + emails, err = fetcher.FetchSentEmails(account, limit, offset) + } else { + emails, err = fetcher.FetchEmails(account, limit, offset) + } if err != nil { return tui.FetchErr(err) } if offset == 0 { - return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID} + return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} } - return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID} + return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} } } @@ -750,7 +894,7 @@ func loadCachedEmails() tea.Cmd { } } -func refreshEmails(cfg *config.Config) tea.Cmd { +func refreshEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { emailsByAccount := make(map[string][]fetcher.Email) var mu sync.Mutex @@ -760,7 +904,13 @@ func refreshEmails(cfg *config.Config) tea.Cmd { wg.Add(1) go func(acc config.Account) { defer wg.Done() - emails, err := fetcher.FetchEmails(&acc, initialEmailLimit, 0) + var emails []fetcher.Email + var err error + if mailbox == tui.MailboxSent { + emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0) + } else { + emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0) + } if err != nil { log.Printf("Error fetching from %s: %v", acc.Email, err) return @@ -772,7 +922,7 @@ func refreshEmails(cfg *config.Config) tea.Cmd { } wg.Wait() - return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount} + return tui.EmailsRefreshedMsg{EmailsByAccount: emailsByAccount, Mailbox: mailbox} } } @@ -820,16 +970,25 @@ func parseEmailAddress(addr string) (name, email string) { return name, email } -func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string) tea.Cmd { +func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { account := cfg.GetAccountByID(accountID) if account == nil { - return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")} + return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")} } - body, attachments, err := fetcher.FetchEmailBody(account, uid) + var ( + body string + attachments []fetcher.Attachment + err error + ) + if mailbox == tui.MailboxSent { + body, attachments, err = fetcher.FetchSentEmailBody(account, uid) + } else { + body, attachments, err = fetcher.FetchEmailBody(account, uid) + } if err != nil { - return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err} + return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } return tui.EmailBodyFetchedMsg{ @@ -837,6 +996,7 @@ func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, acco Body: body, Attachments: attachments, AccountID: accountID, + Mailbox: mailbox, } } } @@ -897,24 +1057,40 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { } } -func deleteEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd { +func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { - err := fetcher.DeleteEmail(account, uid) - return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err} + var err error + if mailbox == tui.MailboxSent { + err = fetcher.DeleteSentEmail(account, uid) + } else { + err = fetcher.DeleteEmail(account, uid) + } + return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } } -func archiveEmailCmd(account *config.Account, uid uint32, accountID string) tea.Cmd { +func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { - err := fetcher.ArchiveEmail(account, uid) - return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Err: err} + var err error + if mailbox == tui.MailboxSent { + err = fetcher.ArchiveSentEmail(account, uid) + } else { + err = fetcher.ArchiveEmail(account, uid) + } + return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } } func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { return func() tea.Msg { // Download and decode the attachment using encoding provided in msg.Encoding. - data, err := fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding) + var data []byte + var err error + if msg.Mailbox == tui.MailboxSent { + data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding) + } else { + data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding) + } if err != nil { return tui.AttachmentDownloadedMsg{Err: err} } diff --git a/tui/choice.go b/tui/choice.go index cae99500ba4f6db8ef214b9a0d68c063c4cefb0e..3c30077295b737ad2afdabccde919176bc1824da 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -40,7 +40,7 @@ type Choice struct { func NewChoice() Choice { hasSavedDrafts := config.HasDrafts() - choices := []string{"View Inbox", "Compose Email"} + choices := []string{"View Inbox", "View Sent", "Compose Email"} if hasSavedDrafts { choices = append(choices, "Drafts") } @@ -75,6 +75,8 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch selectedChoice { case "View Inbox": return m, func() tea.Msg { return GoToInboxMsg{} } + case "View Sent": + return m, func() tea.Msg { return GoToSentInboxMsg{} } case "Compose Email": return m, func() tea.Msg { return GoToSendMsg{} } case "Drafts": diff --git a/tui/email_view.go b/tui/email_view.go index 7bf205edc6f12a9f774f04fe928f7d6ea3ec8538..e7449d1d4e6c9bd273933e54d39defeb68afc866 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -23,9 +23,10 @@ type EmailView struct { attachmentCursor int focusOnAttachments bool accountID string + mailbox MailboxKind } -func NewEmailView(email fetcher.Email, emailIndex, width, height int) *EmailView { +func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind) *EmailView { // Pass the styles from the tui package to the view package body, err := view.ProcessBody(email.Body, H1Style, H2Style, BodyStyle) if err != nil { @@ -53,6 +54,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int) *EmailView email: email, emailIndex: emailIndex, accountID: email.AccountID, + mailbox: mailbox, } } @@ -72,7 +74,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusOnAttachments = false return m, nil } - return m, func() tea.Msg { return BackToInboxMsg{} } + return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} } } if m.focusOnAttachments { @@ -97,6 +99,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { PartID: selected.PartID, Data: selected.Data, AccountID: accountID, + Mailbox: m.mailbox, } } } @@ -111,13 +114,13 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { accountID := m.accountID uid := m.email.UID return m, func() tea.Msg { - return DeleteEmailMsg{UID: uid, AccountID: accountID} + return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox} } case "a": accountID := m.accountID uid := m.email.UID return m, func() tea.Msg { - return ArchiveEmailMsg{UID: uid, AccountID: accountID} + return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox} } case "tab": if len(m.email.Attachments) > 0 { diff --git a/tui/email_view_test.go b/tui/email_view_test.go index cefcc825733ce327d5813dd137a1c12ac3dc3a69..c96b3c39087e69a41c18c83f98635e59199fbde0 100644 --- a/tui/email_view_test.go +++ b/tui/email_view_test.go @@ -28,7 +28,7 @@ func TestEmailViewUpdate(t *testing.T) { } t.Run("Focus on attachments", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) if emailView.focusOnAttachments { t.Error("focusOnAttachments should be initially false") } @@ -50,7 +50,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("No focus on attachments when there are none", func(t *testing.T) { - emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24) + emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24, MailboxInbox) if emailView.focusOnAttachments { t.Error("focusOnAttachments should be initially false") } @@ -63,7 +63,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("Navigate attachments", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) // Focus on attachments model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) emailView = model.(*EmailView) @@ -95,7 +95,7 @@ func TestEmailViewUpdate(t *testing.T) { }) t.Run("Download attachment", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) // Focus on attachments model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) emailView = model.(*EmailView) @@ -118,10 +118,13 @@ func TestEmailViewUpdate(t *testing.T) { if downloadMsg.Filename != "attachment2.txt" { t.Errorf("Expected to download 'attachment2.txt', but got '%s'", downloadMsg.Filename) } + if downloadMsg.Mailbox != MailboxInbox { + t.Errorf("Expected mailbox to be MailboxInbox, got %s", downloadMsg.Mailbox) + } }) t.Run("Reply to email", func(t *testing.T) { - emailView := NewEmailView(emailWithAttachments, 0, 80, 24) + emailView := NewEmailView(emailWithAttachments, 0, 80, 24, MailboxInbox) _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) if cmd == nil { diff --git a/tui/inbox.go b/tui/inbox.go index 5debd04c8cf4e605afb06127fd6139cb644f2c6d..4f1fbc872fab70f3f6e539c64d3b26ebb3e5fdb9 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -94,9 +94,18 @@ type Inbox struct { height int currentAccountID string // Empty means "ALL" emailCountByAcct map[string]int + mailbox MailboxKind } func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { + return NewInboxWithMailbox(emails, accounts, MailboxInbox) +} + +func NewSentInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { + return NewInboxWithMailbox(emails, accounts, MailboxSent) +} + +func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mailbox MailboxKind) *Inbox { // Build tabs: "ALL" + one per account tabs := []AccountTab{{ID: "", Label: "ALL", Email: ""}} for _, acc := range accounts { @@ -124,6 +133,7 @@ func NewInbox(emails []fetcher.Email, accounts []config.Account) *Inbox { activeTabIndex: 0, currentAccountID: "", emailCountByAcct: emailCountByAcct, + mailbox: mailbox, } inbox.updateList() @@ -207,15 +217,15 @@ func (m *Inbox) updateList() { func (m *Inbox) getTitle() string { var title string if m.currentAccountID == "" { - title = "Inbox - All Accounts" + title = m.getBaseTitle() + " - All Accounts" } else { - title = "Inbox" + title = m.getBaseTitle() for _, acc := range m.accounts { if acc.ID == m.currentAccountID { if acc.Name != "" { - title = fmt.Sprintf("Inbox - %s", acc.Name) + title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name) } else { - title = fmt.Sprintf("Inbox - %s", acc.Email) + title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Email) } break } @@ -227,6 +237,15 @@ func (m *Inbox) getTitle() string { return title } +func (m *Inbox) getBaseTitle() string { + switch m.mailbox { + case MailboxSent: + return "Sent" + default: + return "Inbox" + } +} + func (m *Inbox) Init() tea.Cmd { return nil } @@ -264,14 +283,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { selectedItem, ok := m.list.SelectedItem().(item) if ok { return m, func() tea.Msg { - return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID} + return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} } } case "a": selectedItem, ok := m.list.SelectedItem().(item) if ok { return m, func() tea.Msg { - return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID} + return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} } } case "enter": @@ -281,7 +300,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { uid := selectedItem.uid accountID := selectedItem.accountID return m, func() tea.Msg { - return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID} + return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox} } } } @@ -297,6 +316,9 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case EmailsAppendedMsg: + if msg.Mailbox != m.mailbox { + return m, nil + } m.isFetching = false m.list.Title = m.getTitle() @@ -311,11 +333,17 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case RefreshingEmailsMsg: + if msg.Mailbox != m.mailbox { + return m, nil + } m.isRefreshing = true m.list.Title = m.getTitle() return m, nil case EmailsRefreshedMsg: + if msg.Mailbox != m.mailbox { + return m, nil + } m.isRefreshing = false // Replace emails with fresh data @@ -356,7 +384,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // For simplicity, don't auto-fetch more in ALL view } else if accountID != "" { cmds = append(cmds, func() tea.Msg { - return FetchMoreEmailsMsg{Offset: offset, AccountID: accountID} + return FetchMoreEmailsMsg{Offset: offset, AccountID: accountID, Mailbox: m.mailbox} }) } } @@ -414,6 +442,10 @@ func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email { return nil } +func (m *Inbox) GetMailbox() MailboxKind { + return m.mailbox +} + // 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 acc74fce73a19509c8c850401873f61acb4021f6..bbf7507b556a32ba90bde42cdda72cb3184b67e2 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -5,10 +5,18 @@ import ( "github.com/floatpane/matcha/fetcher" ) +type MailboxKind string + +const ( + MailboxInbox MailboxKind = "inbox" + MailboxSent MailboxKind = "sent" +) + type ViewEmailMsg struct { Index int UID uint32 AccountID string + Mailbox MailboxKind } type SendEmailMsg struct { @@ -46,12 +54,16 @@ type ClearStatusMsg struct{} type EmailsFetchedMsg struct { Emails []fetcher.Email AccountID string + Mailbox MailboxKind } type FetchErr error type GoToInboxMsg struct{} +type GoToSentInboxMsg struct{} + + type GoToSendMsg struct { To string Subject string @@ -63,6 +75,7 @@ type GoToSettingsMsg struct{} type FetchMoreEmailsMsg struct { Offset uint32 AccountID string + Mailbox MailboxKind } type FetchingMoreEmailsMsg struct{} @@ -70,6 +83,7 @@ type FetchingMoreEmailsMsg struct{} type EmailsAppendedMsg struct { Emails []fetcher.Email AccountID string + Mailbox MailboxKind } type ReplyToEmailMsg struct { @@ -89,16 +103,19 @@ type CancelFilePickerMsg struct{} type DeleteEmailMsg struct { UID uint32 AccountID string + Mailbox MailboxKind } type ArchiveEmailMsg struct { UID uint32 AccountID string + Mailbox MailboxKind } type EmailActionDoneMsg struct { UID uint32 AccountID string + Mailbox MailboxKind Err error } @@ -111,6 +128,7 @@ type DownloadAttachmentMsg struct { Data []byte AccountID string Encoding string + Mailbox MailboxKind } type AttachmentDownloadedMsg struct { @@ -122,6 +140,10 @@ type RestoreViewMsg struct{} type BackToInboxMsg struct{} +type BackToMailboxMsg struct { + Mailbox MailboxKind +} + // --- Draft Messages --- // DiscardDraftMsg signals that a draft should be cached. @@ -135,6 +157,7 @@ type EmailBodyFetchedMsg struct { Attachments []fetcher.Attachment Err error AccountID string + Mailbox MailboxKind } // --- Multi-Account Messages --- @@ -172,6 +195,7 @@ type SwitchAccountMsg struct { // AllEmailsFetchedMsg signals that emails from all accounts have been fetched. type AllEmailsFetchedMsg struct { EmailsByAccount map[string][]fetcher.Email + Mailbox MailboxKind } // SwitchFromAccountMsg signals changing the "From" account in composer. @@ -230,9 +254,12 @@ type CachedEmailsLoadedMsg struct { } // RefreshingEmailsMsg signals that a background refresh is in progress. -type RefreshingEmailsMsg struct{} +type RefreshingEmailsMsg struct{ + Mailbox MailboxKind +} // EmailsRefreshedMsg signals that fresh emails have been fetched in the background. type EmailsRefreshedMsg struct { EmailsByAccount map[string][]fetcher.Email + Mailbox MailboxKind }