feat: 20 emails + more (#13)

drew created

Change summary

fetcher/fetcher.go      | 124 ++++++++++++++++++++++++++++++++++--------
fetcher/fetcher_test.go |   2 
main.go                 |  37 ++++++++++--
tui/inbox.go            |  49 +++++++++++++++-
tui/messages.go         |  15 ++++
5 files changed, 190 insertions(+), 37 deletions(-)

Detailed changes

fetcher/fetcher.go 🔗

@@ -3,13 +3,18 @@ package fetcher
 import (
 	"fmt"
 	"io"
+	"io/ioutil"
 	"log"
+	"mime"
+	"strings"
 	"time"
 
 	"github.com/andrinoff/email-cli/config"
 	"github.com/emersion/go-imap"
 	"github.com/emersion/go-imap/client"
 	"github.com/emersion/go-message/mail"
+	"golang.org/x/text/encoding/ianaindex"
+	"golang.org/x/text/transform"
 )
 
 type Email struct {
@@ -20,11 +25,58 @@ type Email struct {
 	Date    time.Time
 }
 
-func FetchEmails(cfg *config.Config) ([]Email, error) {
+// ... (decodePart and decodeHeader functions remain the same)
+func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
+	mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
+	if err != nil {
+		body, _ := ioutil.ReadAll(reader)
+		return string(body), nil
+	}
+
+	charset := "utf-8" // Default charset
+	if params["charset"] != "" {
+		charset = strings.ToLower(params["charset"])
+	}
+
+	encoding, err := ianaindex.IANA.Encoding(charset)
+	if err != nil || encoding == nil {
+		encoding, _ = ianaindex.IANA.Encoding("utf-8")
+	}
+
+	transformReader := transform.NewReader(reader, encoding.NewDecoder())
+	decodedBody, err := ioutil.ReadAll(transformReader)
+	if err != nil {
+		return "", err
+	}
+
+	if strings.HasPrefix(mediaType, "multipart/") {
+		return "[This is a multipart message]", nil
+	}
+
+	return string(decodedBody), nil
+}
+
+func decodeHeader(header string) string {
+	dec := new(mime.WordDecoder)
+	dec.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) {
+		encoding, err := ianaindex.IANA.Encoding(charset)
+		if err != nil {
+			return nil, err
+		}
+		return transform.NewReader(input, encoding.NewDecoder()), nil
+	}
+	decoded, err := dec.DecodeHeader(header)
+	if err != nil {
+		return header
+	}
+	return decoded
+}
+
+// FetchEmails now supports pagination with a limit and offset.
+func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
 	var imapServer string
 	var imapPort int
 
-	// Determine the IMAP server based on the service provider in the config.
 	switch cfg.ServiceProvider {
 	case "gmail":
 		imapServer = "imap.gmail.com"
@@ -36,7 +88,6 @@ func FetchEmails(cfg *config.Config) ([]Email, error) {
 		return nil, fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider)
 	}
 
-	// Connect to the server
 	addr := fmt.Sprintf("%s:%d", imapServer, imapPort)
 	c, err := client.DialTLS(addr, nil)
 	if err != nil {
@@ -45,49 +96,62 @@ func FetchEmails(cfg *config.Config) ([]Email, error) {
 	log.Println("Connected")
 	defer c.Logout()
 
-	// Login
 	if err := c.Login(cfg.Email, cfg.Password); err != nil {
 		return nil, err
 	}
 	log.Println("Logged in")
 
-	// Select INBOX
 	mbox, err := c.Select("INBOX", false)
 	if err != nil {
 		return nil, err
 	}
 
-	// Get the last 10 messages
+	// Handle pagination logic
+	if mbox.Messages == 0 {
+		return []Email{}, nil
+	}
+
+	to := mbox.Messages - offset
 	from := uint32(1)
-	to := mbox.Messages
-	if mbox.Messages > 10 {
-		from = mbox.Messages - 9
+	if to > limit {
+		from = to - limit + 1
+	}
+
+	if to < 1 {
+		return []Email{}, nil // No more messages to fetch
 	}
+
 	seqset := new(imap.SeqSet)
 	seqset.AddRange(from, to)
 
-	messages := make(chan *imap.Message, 10)
+	messages := make(chan *imap.Message, limit)
 	done := make(chan error, 1)
 	go func() {
-		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchItem("BODY[]")}, messages)
+		done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchItem("BODY[]")}, messages)
 	}()
 
 	var emails []Email
 	for msg := range messages {
-		r := msg.GetBody(&imap.BodySectionName{})
-		if r == nil {
-			log.Fatal("Server didn't return message body")
+		if msg == nil {
+			continue
 		}
 
-		mr, err := mail.CreateReader(r)
+		bodyLiteral := msg.GetBody(&imap.BodySectionName{})
+		if bodyLiteral == nil {
+			log.Println("Could not get message body")
+			continue
+		}
+
+		mr, err := mail.CreateReader(bodyLiteral)
 		if err != nil {
-			log.Fatal(err)
+			log.Printf("Error creating mail reader: %v", err)
+			continue
 		}
 
 		header := mr.Header
 		fromAddrs, _ := header.AddressList("From")
 		toAddrs, _ := header.AddressList("To")
-		subject, _ := header.Subject()
+		subject := decodeHeader(header.Get("Subject"))
 		date, _ := header.Date()
 
 		var fromAddr string
@@ -100,18 +164,31 @@ func FetchEmails(cfg *config.Config) ([]Email, error) {
 			toAddrList = append(toAddrList, addr.Address)
 		}
 
-		p, err := mr.NextPart()
-		if err != nil && err != io.EOF {
-			log.Fatal(err)
+		var body string
+		for {
+			p, err := mr.NextPart()
+			if err == io.EOF {
+				break
+			} else if err != nil {
+				log.Printf("Error getting next part: %v", err)
+				break
+			}
+
+			mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type"))
+			if mediaType == "text/plain" || mediaType == "text/html" {
+				decodedPart, decodeErr := decodePart(p.Body, p.Header)
+				if decodeErr == nil {
+					body = decodedPart
+					break
+				}
+			}
 		}
 
-		body, _ := io.ReadAll(p.Body)
-
 		emails = append(emails, Email{
 			From:    fromAddr,
 			To:      toAddrList,
 			Subject: subject,
-			Body:    string(body),
+			Body:    body,
 			Date:    date,
 		})
 	}
@@ -120,7 +197,6 @@ func FetchEmails(cfg *config.Config) ([]Email, error) {
 		return nil, err
 	}
 
-	// Reverse the order of emails to be from newest to oldest.
 	for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
 		emails[i], emails[j] = emails[j], emails[i]
 	}

fetcher/fetcher_test.go 🔗

@@ -23,7 +23,7 @@ func TestFetchEmails(t *testing.T) {
 		t.Skip("Skipping TestFetchEmails: placeholder or empty password found in config.")
 	}
 
-	emails, err := FetchEmails(cfg)
+	emails, err := FetchEmails(cfg, 10, 10)
 	if err != nil {
 		t.Fatalf("FetchEmails() failed with error: %v", err)
 	}

main.go 🔗

@@ -21,6 +21,11 @@ import (
 	"github.com/yuin/goldmark/renderer/html"
 )
 
+const (
+	initialEmailLimit = 20
+	paginationLimit   = 20
+)
+
 // mainModel holds the state for the entire application.
 type mainModel struct {
 	current tea.Model
@@ -79,7 +84,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 
-	// --- Custom Messages for Switching Views ---
+	// --- Custom Messages for Switching Views and Pagination ---
 	case tui.Credentials:
 		cfg := &config.Config{
 			ServiceProvider: msg.Provider,
@@ -97,7 +102,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 	case tui.GoToInboxMsg:
 		m.current = tui.NewStatus("Fetching emails...")
-		return m, tea.Batch(m.current.Init(), fetchEmails(m.config))
+		return m, tea.Batch(m.current.Init(), fetchEmails(m.config, initialEmailLimit, 0))
 
 	case tui.EmailsFetchedMsg:
 		m.emails = msg.Emails
@@ -107,6 +112,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
 		cmds = append(cmds, m.current.Init())
 
+	case tui.FetchMoreEmailsMsg:
+		cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} })
+		cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset))
+		return m, tea.Batch(cmds...)
+
+	case tui.EmailsAppendedMsg:
+		m.emails = append(m.emails, msg.Emails...)
+		// Pass the new emails to the inbox to be appended.
+		m.current, cmd = m.current.Update(msg)
+		cmds = append(cmds, cmd)
+		return m, tea.Batch(cmds...)
+
 	case tui.GoToSendMsg:
 		m.current = tui.NewComposer(m.config.Email)
 		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
@@ -193,14 +210,19 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
 	}
 }
 
-// fetchEmails retrieves emails in the background.
-func fetchEmails(cfg *config.Config) tea.Cmd {
+// fetchEmails retrieves emails in the background and dispatches the correct message.
+func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
 	return func() tea.Msg {
-		emails, err := fetcher.FetchEmails(cfg)
+		emails, err := fetcher.FetchEmails(cfg, limit, offset)
 		if err != nil {
 			return tui.FetchErr(err)
 		}
-		return tui.EmailsFetchedMsg{Emails: emails}
+		if offset == 0 {
+			// This is the initial fetch.
+			return tui.EmailsFetchedMsg{Emails: emails}
+		}
+		// This is a subsequent fetch for pagination.
+		return tui.EmailsAppendedMsg{Emails: emails}
 	}
 }
 
@@ -208,6 +230,7 @@ func main() {
 	cfg, err := config.LoadConfig()
 	var initialModel *mainModel
 	if err != nil {
+		// If config doesn't exist, guide the user to create one via the login view.
 		initialModel = newInitialModel(nil)
 	} else {
 		initialModel = newInitialModel(cfg)
@@ -219,4 +242,4 @@ func main() {
 		fmt.Printf("Alas, there's been an error: %v", err)
 		os.Exit(1)
 	}
-}
+}

tui/inbox.go 🔗

@@ -46,8 +46,11 @@ 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
+	list        list.Model
+	isFetching  bool
+	emailsCount int
 }
 
 func NewInbox(emails []fetcher.Email) *Inbox {
@@ -61,13 +64,18 @@ func NewInbox(emails []fetcher.Email) *Inbox {
 
 	l := list.New(items, itemDelegate{}, 20, 14)
 	l.Title = "Inbox"
-	l.SetShowStatusBar(false)
+	l.SetShowStatusBar(true)
 	l.SetFilteringEnabled(true)
 	l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
 	l.Styles.PaginationStyle = paginationStyle
 	l.Styles.HelpStyle = inboxHelpStyle
+	l.SetStatusBarItemName("email", "emails")
 
-	return &Inbox{list: l}
+	return &Inbox{
+		list:        l,
+		isFetching:  false,
+		emailsCount: len(emails),
+	}
 }
 
 func (m *Inbox) Init() tea.Cmd {
@@ -75,6 +83,8 @@ func (m *Inbox) Init() tea.Cmd {
 }
 
 func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		if msg.String() == "enter" {
@@ -89,13 +99,44 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.list.SetWidth(msg.Width)
 		return m, nil
+
+	case FetchingMoreEmailsMsg:
+		m.isFetching = true
+		m.list.Title = "Fetching more emails..."
+		return m, nil
+
+	case EmailsAppendedMsg:
+		m.isFetching = false
+		m.list.Title = "Inbox"
+		newItems := make([]list.Item, len(msg.Emails))
+		for i, email := range msg.Emails {
+			newItems[i] = item{
+				title: email.Subject,
+				desc:  email.From,
+			}
+		}
+		// Correctly append new items to the list.
+		currentItems := m.list.Items()
+		allItems := append(currentItems, newItems...)
+		cmd := m.list.SetItems(allItems)
+		m.emailsCount += len(msg.Emails)
+		cmds = append(cmds, cmd)
+		return m, tea.Batch(cmds...)
+	}
+
+	// Infinite scroll logic
+	if !m.isFetching && m.list.Index() >= len(m.list.Items())-5 {
+		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
-	return m, cmd
+	cmds = append(cmds, cmd)
+	return m, tea.Batch(cmds...)
 }
 
 func (m *Inbox) View() string {

tui/messages.go 🔗

@@ -51,4 +51,17 @@ type GoToInboxMsg struct{}
 type GoToSendMsg struct{}
 
 // A message to navigate to the settings view.
-type GoToSettingsMsg struct{}
+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
+}