diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 9a2bf56454825b5289720cd0d7bc903f5a6a7511..c7c9ee4d4e2597b5598ed9adc047e05f4ee99870 100644 --- a/fetcher/fetcher.go +++ b/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] } diff --git a/fetcher/fetcher_test.go b/fetcher/fetcher_test.go index 93de6d79654ce502a2566abff6a92b6b60cc8682..23985a2d809af5c6408290183c33e7007fd98383 100644 --- a/fetcher/fetcher_test.go +++ b/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) } diff --git a/main.go b/main.go index d361a8cdbae2095997621d2c529961d148619fc6..a01562b1f913c89245e8af1f86e383ddd2153936 100644 --- a/main.go +++ b/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) } -} \ No newline at end of file +} diff --git a/tui/inbox.go b/tui/inbox.go index 78456c691418cee49f938227e313598d5cc9b82d..ff6f1ecd8326781d3eff9d1c4c87f708d42e1a7d 100644 --- a/tui/inbox.go +++ b/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 { diff --git a/tui/messages.go b/tui/messages.go index 91004e28b0eb2913ec75dc5a5a80f485943e4f60..36da0b9e365d1715f331f57b1bfe097cc02104cb 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -51,4 +51,17 @@ type GoToInboxMsg struct{} type GoToSendMsg struct{} // A message to navigate to the settings view. -type GoToSettingsMsg struct{} \ No newline at end of file +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 +} \ No newline at end of file