Detailed changes
@@ -0,0 +1,7 @@
+Copyright 2025-present Drew Smirnoff
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the βSoftwareβ), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED βAS ISβ, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -63,4 +63,18 @@ func LoadConfig() (*Config, error) {
return nil, err
}
return &config, nil
+}
+// IMAPServer returns the IMAP server address based on the service provider.
+// This is used to connect to the email provider's IMAP server.
+// It returns an empty string if the service provider is not supported.
+func (c *Config) IMAPServer() string {
+ switch c.ServiceProvider {
+ case "gmail":
+ return "imap.gmail.com"
+ case "icloud":
+ return "imap.mail.me.com"
+ // Add other providers here
+ default:
+ return ""
+ }
}
@@ -0,0 +1,129 @@
+package fetcher
+
+import (
+ "crypto/tls"
+ "fmt"
+ "io"
+ "log"
+ "time"
+
+ "github.com/andrinoff/email-cli/config"
+ "github.com/emersion/go-imap/v2"
+ "github.com/emersion/go-imap/v2/imapclient"
+)
+
+// Email struct holds the essential information for a single email.
+type Email struct {
+ From string
+ Subject string
+ Body string
+ Date time.Time
+}
+
+// FetchEmails connects to the email provider's IMAP server and retrieves the latest emails.
+func FetchEmails(cfg *config.Config) ([]Email, error) {
+ var imapHost, serverName string
+ switch cfg.ServiceProvider {
+ case "gmail":
+ imapHost = "imap.gmail.com:993"
+ serverName = "imap.gmail.com"
+ case "icloud":
+ imapHost = "imap.mail.me.com:993"
+ serverName = "imap.mail.me.com"
+ default:
+ return nil, fmt.Errorf("unsupported service provider: %s", cfg.ServiceProvider)
+ }
+
+ // Set up client options
+ options := imapclient.Options{
+ TLSConfig: &tls.Config{ServerName: serverName},
+ }
+ // Dial the IMAP server
+ c, err := imapclient.DialTLS(imapHost, &options)
+ if err != nil {
+ return nil, fmt.Errorf("failed to dial IMAP server: %w", err)
+ }
+ defer c.Close()
+
+ // Login
+ if err := c.Login(cfg.Email, cfg.Password).Wait(); err != nil {
+ return nil, fmt.Errorf("failed to login: %w", err)
+ }
+
+ // Select INBOX to get message count
+ selectData, err := c.Select("INBOX", nil).Wait()
+ if err != nil {
+ return nil, fmt.Errorf("failed to select INBOX: %w", err)
+ }
+ numMessages := selectData.NumMessages
+
+ if numMessages == 0 {
+ return []Email{}, nil // No messages
+ }
+
+ // Determine the sequence set for the last 10 messages
+ start := uint32(1)
+ if numMessages > 10 {
+ start = numMessages - 9
+ }
+ seqSet := imap.SeqSetNum(start, numMessages)
+
+ // Define what to fetch for each message
+ fetchOptions := &imap.FetchOptions{
+ Envelope: true,
+ BodySection: []*imap.FetchItemBodySection{
+ {Specifier: imap.PartSpecifierText},
+ },
+ }
+
+ // Fetch the messages
+ cmd := c.Fetch(seqSet, fetchOptions)
+ defer cmd.Close()
+
+ var emails []Email
+ for {
+ msg := cmd.Next()
+ if msg == nil {
+ break // All messages have been processed
+ }
+
+ var from, subject, body string
+ var date time.Time
+
+ // Get header info from the envelope
+ if env := msg.Envelope(); env != nil {
+ if len(env.From) > 0 {
+ from = env.From[0].Address()
+ }
+ subject = env.Subject
+ date = env.Date
+ }
+
+ // Read the body content
+ if part := msg.BodySection(&imap.FetchItemBodySection{Specifier: imap.PartSpecifierText}); part != nil {
+ bodyBytes, err := io.ReadAll(part)
+ if err != nil {
+ log.Printf("failed to read body part: %v", err)
+ continue
+ }
+ body = string(bodyBytes)
+ }
+
+ emails = append(emails, Email{
+ From: from,
+ Subject: subject,
+ Body: body,
+ Date: date,
+ })
+ }
+ if err := cmd.Err(); err != nil {
+ return nil, fmt.Errorf("FETCH command failed: %w", err)
+ }
+
+ // Reverse the slice to show the newest emails first
+ for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 {
+ emails[i], emails[j] = emails[j], emails[i]
+ }
+
+ return emails, nil
+}
@@ -12,6 +12,9 @@ require (
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/emersion/go-imap/v2 v2.0.0-beta.5 // indirect
+ github.com/emersion/go-message v0.18.2 // indirect
+ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
@@ -22,6 +25,7 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -31,9 +31,22 @@ func main() {
}
}
- // Now that we have a config, run the main email composer UI
- fmt.Printf("Logged in as %s. Starting email composer...\n", cfg.Email)
- if err := tui.RunComposer(cfg); err != nil {
- log.Fatalf("Application error: %v", err)
+ // Now that we have a config, let the user choose what to do
+ choice, err := tui.RunChoice()
+ if err != nil {
+ log.Fatalf("Could not run choice UI: %v", err)
+ }
+
+ switch choice {
+ case "send":
+ fmt.Printf("Logged in as %s. Starting email composer...\n", cfg.Email)
+ if err := tui.RunComposer(cfg); err != nil {
+ log.Fatalf("Application error: %v", err)
+ }
+ case "inbox":
+ fmt.Printf("Logged in as %s. Opening inbox...\n", cfg.Email)
+ if err := tui.RunInbox(cfg); err != nil {
+ log.Fatalf("Application error: %v", err)
+ }
}
}
@@ -0,0 +1,80 @@
+package tui
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/charmbracelet/bubbles/list"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type choiceModel struct {
+ list list.Model
+ choice string
+}
+
+func (m choiceModel) Init() tea.Cmd {
+ return nil
+}
+
+func (m choiceModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ switch keypress := msg.String(); keypress {
+ case "ctrl+c", "q":
+ return m, tea.Quit
+
+ case "enter":
+ i, ok := m.list.SelectedItem().(item)
+ if ok {
+ m.choice = string(i.title)
+ }
+ return m, tea.Quit
+ }
+ case tea.WindowSizeMsg:
+ h, v := DialogBoxStyle.GetFrameSize()
+ m.list.SetSize(msg.Width-h, msg.Height-v)
+ }
+
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func (m choiceModel) View() string {
+ return DialogBoxStyle.Render(m.list.View())
+}
+
+type item struct {
+ title, desc string
+}
+
+func (i item) Title() string { return i.title }
+func (i item) Description() string { return i.desc }
+func (i item) FilterValue() string { return i.title }
+
+func RunChoice() (string, error) {
+ items := []list.Item{
+ item{title: "send", desc: "Send a new email"},
+ item{title: "inbox", desc: "View your inbox"},
+ }
+
+ l := list.New(items, list.NewDefaultDelegate(), 0, 0)
+ l.Title = "What would you like to do?"
+ l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
+
+ m := choiceModel{list: l}
+
+ p := tea.NewProgram(m, tea.WithAltScreen())
+
+ mod, err := p.Run()
+ if err != nil {
+ fmt.Println("Error running program:", err)
+ os.Exit(1)
+ }
+ if m, ok := mod.(choiceModel); ok && m.choice != "" {
+ return m.choice, nil
+ }
+ return "", fmt.Errorf("no choice made")
+}
@@ -0,0 +1,130 @@
+
+
+package tui
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/andrinoff/email-cli/config"
+ "github.com/andrinoff/email-cli/fetcher"
+ "github.com/charmbracelet/bubbles/list"
+ "github.com/charmbracelet/bubbles/spinner"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type inboxModel struct {
+ spinner spinner.Model
+ loading bool
+ emails []fetcher.Email
+ list list.Model
+ err error
+ config *config.Config
+ selected bool
+}
+
+func (m inboxModel) Init() tea.Cmd {
+ return m.spinner.Tick
+}
+
+func (m inboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if msg.String() == "ctrl+c" || msg.String() == "q" {
+ return m, tea.Quit
+ }
+ if msg.String() == "enter" {
+ m.selected = true
+ return m, nil
+ }
+ case tea.WindowSizeMsg:
+ h, v := DialogBoxStyle.GetFrameSize()
+ m.list.SetSize(msg.Width-h, msg.Height-v)
+ case []fetcher.Email:
+ m.loading = false
+ m.emails = msg
+ items := make([]list.Item, len(m.emails))
+ for i, email := range m.emails {
+ items[i] = item{
+ title: email.Subject,
+ desc: fmt.Sprintf("From: %s", email.From),
+ }
+ }
+ m.list.SetItems(items)
+
+ case error:
+ m.err = msg
+ return m, nil
+ }
+
+ var cmd tea.Cmd
+ if m.loading {
+ m.spinner, cmd = m.spinner.Update(msg)
+ } else {
+ m.list, cmd = m.list.Update(msg)
+ }
+ return m, cmd
+}
+
+func (m inboxModel) View() string {
+ if m.err != nil {
+ return InfoStyle.Render(fmt.Sprintf("\n Error: %v \n\n Press any key to exit.", m.err))
+ }
+ if m.loading {
+ return InfoStyle.Render(fmt.Sprintf("\n %s Fetching emails... \n\n", m.spinner.View()))
+ }
+ if m.selected {
+ i, ok := m.list.SelectedItem().(item)
+ if !ok {
+ return "error"
+ }
+ var selectedEmail fetcher.Email
+ for _, email := range m.emails {
+ if email.Subject == i.Title() {
+ selectedEmail = email
+ break
+ }
+ }
+ var s strings.Builder
+ s.WriteString(fmt.Sprintf("From: %s\n", selectedEmail.From))
+ s.WriteString(fmt.Sprintf("Subject: %s\n", selectedEmail.Subject))
+ s.WriteString(fmt.Sprintf("Date: %s\n\n", selectedEmail.Date.Format("2006-01-02 15:04:05")))
+ s.WriteString(selectedEmail.Body)
+ s.WriteString(HelpStyle.Render("\n\n(q to quit)"))
+ return DialogBoxStyle.Render(s.String())
+
+ }
+ return DialogBoxStyle.Render(m.list.View())
+}
+
+func initialInboxModel(cfg *config.Config) inboxModel {
+ s := spinner.New()
+ s.Spinner = spinner.Dot
+ s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+ l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
+ l.Title = "Inbox"
+ l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
+ return inboxModel{
+ spinner: s,
+ loading: true,
+ config: cfg,
+ list: l,
+ }
+}
+
+func RunInbox(cfg *config.Config) error {
+ m := initialInboxModel(cfg)
+ p := tea.NewProgram(m, tea.WithAltScreen())
+
+ go func() {
+ emails, err := fetcher.FetchEmails(cfg)
+ if err != nil {
+ p.Send(err)
+ }
+ p.Send(emails)
+ }()
+
+ _, err := p.Run()
+ return err
+}