diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 4849ec2046e18a2878cb5f409aafb2aa02c2ed15..f4b80f5927687fe3aecb75d1ed4a2fbbaa1755da 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -18,12 +18,13 @@ import ( ) type Email struct { - From string - To []string - Subject string - Body string - Date time.Time - MessageID string + UID uint32 // Added UID to uniquely identify emails + From string + To []string + Subject string + Body string + Date time.Time + MessageID string References []string } @@ -34,7 +35,7 @@ func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { return string(body), nil } - charset := "utf-8" // Default charset + charset := "utf-8" if params["charset"] != "" { charset = strings.ToLower(params["charset"]) } @@ -73,8 +74,8 @@ func decodeHeader(header string) string { return decoded } -// FetchEmails now supports pagination with a limit and offset. -func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { +// connect initializes a connection to the IMAP server. +func connect(cfg *config.Config) (*client.Client, error) { var imapServer string var imapPort int @@ -86,7 +87,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { imapServer = "imap.mail.me.com" imapPort = 993 default: - return nil, fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider) + return nil, fmt.Errorf("unsupported service_provider: %s", cfg.ServiceProvider) } addr := fmt.Sprintf("%s:%d", imapServer, imapPort) @@ -94,20 +95,26 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { if err != nil { return nil, err } - log.Println("Connected") - defer c.Logout() if err := c.Login(cfg.Email, cfg.Password); err != nil { return nil, err } - log.Println("Logged in") + + return c, nil +} + +func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { + c, err := connect(cfg) + if err != nil { + return nil, err + } + defer c.Logout() mbox, err := c.Select("INBOX", false) if err != nil { return nil, err } - // Handle pagination logic if mbox.Messages == 0 { return []Email{}, nil } @@ -119,7 +126,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } if to < 1 { - return []Email{}, nil // No more messages to fetch + return []Email{}, nil } seqset := new(imap.SeqSet) @@ -127,8 +134,9 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { messages := make(chan *imap.Message, limit) done := make(chan error, 1) + fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchItem("BODY[]")} go func() { - done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchItem("BODY[]")}, messages) + done <- c.Fetch(seqset, fetchItems, messages) }() var emails []Email @@ -188,6 +196,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } emails = append(emails, Email{ + UID: msg.Uid, From: fromAddr, To: toAddrList, Subject: subject, @@ -207,4 +216,46 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } return emails, nil +} + +// moveEmail moves an email to a specified destination mailbox. +func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { + c, err := connect(cfg) + if err != nil { + return err + } + defer c.Logout() + + if _, err := c.Select("INBOX", false); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + return c.UidMove(seqSet, destMailbox) +} + +// DeleteEmail moves an email to the trash folder. +func DeleteEmail(cfg *config.Config, uid uint32) error { + var trashMailbox string + switch cfg.ServiceProvider { + case "gmail": + trashMailbox = "[Gmail]/Trash" + default: + trashMailbox = "Trash" + } + return moveEmail(cfg, uid, trashMailbox) +} + +// ArchiveEmail moves an email to the archive folder. +func ArchiveEmail(cfg *config.Config, uid uint32) error { + var archiveMailbox string + switch cfg.ServiceProvider { + case "gmail": + archiveMailbox = "[Gmail]/All Mail" + default: + archiveMailbox = "Archive" + } + return moveEmail(cfg, uid, archiveMailbox) } \ No newline at end of file diff --git a/main.go b/main.go index 67ed463bd4a5d62856a9da545d0f5864c602781e..d3a5ea80f0d88f33bae192960bc179b9485afb1a 100644 --- a/main.go +++ b/main.go @@ -26,10 +26,9 @@ const ( paginationLimit = 20 ) -// mainModel holds the state for the entire application. type mainModel struct { current tea.Model - previousModel tea.Model // To store the view before opening the file picker + previousModel tea.Model config *config.Config emails []fetcher.Email inbox *tui.Inbox @@ -38,7 +37,6 @@ type mainModel struct { err error } -// newInitialModel returns a pointer to the initial model. func newInitialModel(cfg *config.Config) *mainModel { if cfg == nil { return &mainModel{ @@ -72,7 +70,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } if msg.String() == "esc" { - // If we are in a nested view like email or file picker, handle it here switch m.current.(type) { case *tui.EmailView: m.current = m.inbox @@ -154,10 +151,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.FileSelectedMsg: if m.previousModel != nil { - // Send the selection message to the previous model (the composer) m.previousModel, cmd = m.previousModel.Update(msg) cmds = append(cmds, cmd) - // Return to the composer view m.current = m.previousModel m.previousModel = nil } @@ -177,6 +172,36 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.EmailResultMsg: m.current = tui.NewChoice() cmds = append(cmds, m.current.Init()) + + case tui.DeleteEmailMsg: + m.current = tui.NewStatus("Deleting email...") + cmds = append(cmds, m.current.Init(), deleteEmailCmd(m.config, msg.UID)) + + case tui.ArchiveEmailMsg: + m.current = tui.NewStatus("Archiving email...") + cmds = append(cmds, m.current.Init(), archiveEmailCmd(m.config, msg.UID)) + + case tui.EmailActionDoneMsg: + if msg.Err != nil { + // In a real app, you might show an error message. + // For now, we'll just go back to the inbox. + log.Printf("Action failed: %v", msg.Err) + m.current = m.inbox + return m, nil + } + // Remove the email from the local cache + var updatedEmails []fetcher.Email + for _, email := range m.emails { + if email.UID != msg.UID { + updatedEmails = append(updatedEmails, email) + } + } + m.emails = updatedEmails + // Refresh the inbox view + m.inbox = tui.NewInbox(m.emails) + m.current = m.inbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() } m.current, cmd = m.current.Update(msg) @@ -259,6 +284,20 @@ func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd { } } +func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { + return func() tea.Msg { + err := fetcher.DeleteEmail(cfg, uid) + return tui.EmailActionDoneMsg{UID: uid, Err: err} + } +} + +func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { + return func() tea.Msg { + err := fetcher.ArchiveEmail(cfg, uid) + return tui.EmailActionDoneMsg{UID: uid, Err: err} + } +} + func main() { cfg, err := config.LoadConfig() var initialModel *mainModel diff --git a/tui/inbox.go b/tui/inbox.go index f470a6eb818c00d8ea85c6fc2fe603312419ea2e..ee67c31b85872aafae7feda185239e9bd1220f13 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -5,6 +5,7 @@ import ( "io" "github.com/andrinoff/email-cli/fetcher" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -15,10 +16,10 @@ var ( inboxHelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) ) -// item now holds its original index from the main email slice. type item struct { title, desc string originalIndex int + uid uint32 // Added UID to item } func (i item) Title() string { return i.title } @@ -48,7 +49,6 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list fmt.Fprint(w, fn(str)) } -// Inbox is now stateful to handle pagination. type Inbox struct { list list.Model isFetching bool @@ -61,7 +61,8 @@ func NewInbox(emails []fetcher.Email) *Inbox { items[i] = item{ title: email.Subject, desc: email.From, - originalIndex: i, // Store the original index here. + originalIndex: i, + uid: email.UID, // Store UID } } @@ -73,6 +74,12 @@ func NewInbox(emails []fetcher.Email) *Inbox { l.Styles.PaginationStyle = paginationStyle l.Styles.HelpStyle = inboxHelpStyle l.SetStatusBarItemName("email", "emails") + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")), + } + } return &Inbox{ list: l, @@ -90,13 +97,28 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - // When the user presses enter, we look at the selected item and send - // a message with its *original* index. - if msg.String() == "enter" { + if m.list.FilterState() == list.Filtering { + break + } + switch keypress := msg.String(); keypress { + case "d": + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + return m, func() tea.Msg { + return DeleteEmailMsg{UID: selectedItem.uid} + } + } + case "a": + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + return m, func() tea.Msg { + return ArchiveEmailMsg{UID: selectedItem.uid} + } + } + case "enter": selectedItem, ok := m.list.SelectedItem().(item) if ok { return m, func() tea.Msg { - // Use the stored original index, which is correct even when filtered. return ViewEmailMsg{Index: selectedItem.originalIndex} } } @@ -118,7 +140,8 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newItems[i] = item{ title: email.Subject, desc: email.From, - originalIndex: m.emailsCount + i, // The original index continues to grow. + originalIndex: m.emailsCount + i, + uid: email.UID, } } currentItems := m.list.Items() @@ -129,26 +152,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } - // Infinite scroll logic if !m.isFetching && len(m.list.Items()) > 0 && m.list.Index() >= len(m.list.Items())-5 { cmds = append(cmds, func() tea.Msg { return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)} }) } - // New logic to fetch more emails when filtering is active - if m.list.FilterState() == list.Filtering && !m.isFetching { - m.isFetching = true - m.list.Title = "Fetching more emails..." - cmds = append(cmds, func() tea.Msg { - return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)} - }) - } - var cmd tea.Cmd - var newModel list.Model - newModel, cmd = m.list.Update(msg) - m.list = newModel + m.list, cmd = m.list.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } diff --git a/tui/messages.go b/tui/messages.go index c65c6d3b02fd812d3b7c52df79e595e67e1c63de..fc423ebd57715285b4fa84f56106f37a56d94505 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -2,12 +2,10 @@ package tui import "github.com/andrinoff/email-cli/fetcher" -// A message to view an email. type ViewEmailMsg struct { Index int } -// A message to indicate that an email has been sent. type SendEmailMsg struct { To string Subject string @@ -17,7 +15,6 @@ type SendEmailMsg struct { References []string } -// A message to indicate that the user has entered their credentials. type Credentials struct { Provider string Name string @@ -25,71 +22,68 @@ type Credentials struct { Password string } -// A message to indicate that the user has chosen a service. type ChooseServiceMsg struct { Service string } -// EmailResultMsg is sent after an email sending attempt. -// If Err is not nil, the email failed to send. type EmailResultMsg struct { Err error } -// ClearStatusMsg is sent to clear the status message from the view. type ClearStatusMsg struct{} -// A message containing the fetched emails. type EmailsFetchedMsg struct { Emails []fetcher.Email } -// A message to indicate that an error occurred while fetching emails. type FetchErr error -// A message to navigate to the inbox view. type GoToInboxMsg struct{} -// A message to navigate to the composer view. type GoToSendMsg struct { To string Subject string Body string } -// A message to navigate to the settings view. type GoToSettingsMsg struct{} -// A message to fetch more emails with a given offset. type FetchMoreEmailsMsg struct { Offset uint32 } -// A message to indicate that the app is fetching more emails. type FetchingMoreEmailsMsg struct{} -// A message to indicate that new emails have been fetched and should be appended. type EmailsAppendedMsg struct { Emails []fetcher.Email } -// A message to reply to an email. type ReplyToEmailMsg struct { Email fetcher.Email } -// A message to set the composer cursor to the start. type SetComposerCursorToStartMsg struct{} -// --- File Picker Messages --- - -// A message to open the file picker. type GoToFilePickerMsg struct{} -// A message sent when a file has been selected. type FileSelectedMsg struct { Path string } -// A message to cancel the file picker and return to the previous view. -type CancelFilePickerMsg struct{} \ No newline at end of file +type CancelFilePickerMsg struct{} + +// --- Email Action Messages --- + +type DeleteEmailMsg struct { + UID uint32 +} + +type ArchiveEmailMsg struct { + UID uint32 +} + +// EmailActionDoneMsg reports the result of an action like delete or archive. +type EmailActionDoneMsg struct { + UID uint32 + Err error +} \ No newline at end of file