diff --git a/LICENSE b/LICENSE index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ec328bbb4417bcd03dd4f8e2125f2c7c07868dd0 100644 --- a/LICENSE +++ b/LICENSE @@ -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. diff --git a/config/config.go b/config/config.go index b6dfd95467ea677eed7015f83e395380f4333992..1b1029215da8a465152fb0c8730616e0c4dd6502 100644 --- a/config/config.go +++ b/config/config.go @@ -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 "" + } } \ No newline at end of file diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go new file mode 100644 index 0000000000000000000000000000000000000000..0ba8613faab5ab3fceacb021ca5be2b2b5aa4053 --- /dev/null +++ b/fetcher/fetcher.go @@ -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 +} diff --git a/go.mod b/go.mod index 5cf43373dd12beea2b369ae03867ade7e9d7c2ac..4dcb70735c34919cdbab7271b2a6e3f2243ed5e2 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/main.go b/main.go index c47de7b39a775e1f536bebbdb74efdd6fe7bff29..bf89f6d2d79f22cb5e1c6cda0681f306f9ad93b8 100644 --- a/main.go +++ b/main.go @@ -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) + } } } \ No newline at end of file diff --git a/tui/choice.go b/tui/choice.go new file mode 100644 index 0000000000000000000000000000000000000000..0edecf1203a2c241539b606c13e2040d895866b3 --- /dev/null +++ b/tui/choice.go @@ -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") +} diff --git a/tui/inbox.go b/tui/inbox.go new file mode 100644 index 0000000000000000000000000000000000000000..856272b9b6d1fb71c30a416ad09736c1bb2e82ff --- /dev/null +++ b/tui/inbox.go @@ -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 +} \ No newline at end of file