feat: archive and delete emails (#27)

drew created

Change summary

fetcher/fetcher.go | 83 ++++++++++++++++++++++++++++++++++++++---------
main.go            | 51 ++++++++++++++++++++++++++---
tui/inbox.go       | 53 ++++++++++++++++++------------
tui/messages.go    | 40 +++++++++-------------
4 files changed, 161 insertions(+), 66 deletions(-)

Detailed changes

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)
 }

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

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...)
 }

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{}
+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
+}