From d085aeab395275f5ae9117d95ac335ac49719d26 Mon Sep 17 00:00:00 2001 From: drew Date: Mon, 28 Jul 2025 12:48:48 +0400 Subject: [PATCH] fix: unknown window appear (DOESNT COMPILE) --- main.go | 237 +++++++++++++++++++++++++++++++++--------------- tui/choice.go | 83 ++++++++++------- tui/inbox.go | 148 ++++++++++++++---------------- tui/login.go | 137 ++++++++++------------------ tui/messages.go | 41 ++++++--- 5 files changed, 358 insertions(+), 288 deletions(-) diff --git a/main.go b/main.go index 8243d30ccb569fe447ec6aa1817d2ea376a5df0d..95649aa9f13fe301059c8bad85a34bf8e6d38a67 100644 --- a/main.go +++ b/main.go @@ -1,122 +1,211 @@ package main import ( - "fmt" "log" "os" "time" + // These imports were missing, causing all the "undefined" errors. "github.com/andrinoff/email-cli/config" + "github.com/andrinoff/email-cli/fetcher" "github.com/andrinoff/email-cli/sender" "github.com/andrinoff/email-cli/tui" + tea "github.com/charmbracelet/bubbletea" ) -// mainModel now holds the state for the entire application. -type mainModel struct { - current tea.Model - config *config.Config - width int - height int - err error +type modelState int + +const ( + loginView modelState = iota + mainMenu + inboxView + emailView + composeView +) + +// Model holds the application's state. +type Model struct { + state modelState + login tea.Model + mainMenu tea.Model + inbox tea.Model + emailView tea.Model + composer tea.Model + fetcher *fetcher.Fetcher + sender *sender.Sender + emails []fetcher.Email + statusMessage string + err error } -// newInitialModel now returns a pointer, which is crucial for state management. -func newInitialModel(cfg *config.Config) *mainModel { - return &mainModel{ - current: tui.NewChoice(), - config: cfg, +// New creates the initial model. +func New() Model { + // We start with the login view. + return Model{ + state: loginView, + login: tui.NewLogin(), + mainMenu: tui.NewChoice(), } } -func (m *mainModel) Init() tea.Cmd { - return m.current.Init() +// Init runs any initial commands. +func (m Model) Init() tea.Cmd { + return tea.EnterAltScreen } -func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// Update handles all messages and updates the model. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + // Global messages that can be handled regardless of state. switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - // Pass the window size message to the current view - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - case tea.KeyMsg: if msg.String() == "ctrl+c" { return m, tea.Quit } - // Allow ESC to go back to the main menu - if msg.String() == "esc" { - if _, ok := m.current.(*tui.Choice); !ok { - m.current = tui.NewChoice() - return m, m.current.Init() + case tui.EmailResultMsg: + if msg.Err != nil { + m.statusMessage = "❌ Error: " + msg.Err.Error() + } else { + m.statusMessage = "✅ Email sent successfully!" + } + m.state = mainMenu + return m, tea.Tick(3*time.Second, func(t time.Time) tea.Msg { + return tui.ClearStatusMsg{} + }) + case tui.ClearStatusMsg: + m.statusMessage = "" + return m, nil + } + + // State-specific message handling. + switch m.state { + case loginView: + // When the user submits their credentials... + if msg, ok := msg.(tui.Credentials); ok { + // For now, we'll default to Gmail. + service := "gmail" + cfg := &config.Config{Email: msg.Email, Password: msg.Password, Service: service} + config.Save(cfg) + m.fetcher = fetcher.New(cfg) + m.sender = sender.New(msg.Email, msg.Password, service) + m.state = mainMenu + return m, nil + } + + case mainMenu: + // Handle user's choice from the main menu. + if msg, ok := msg.(tea.KeyMsg); ok { + switch msg.String() { + case "1", "j", "down": // View Inbox + m.state = inboxView + m.inbox = tui.NewInbox(m.emails) + return m, func() tea.Msg { + emails, err := m.fetcher.FetchEmails("INBOX") + if err != nil { + return tui.FetchErr(err) + } + return tui.EmailsFetchedMsg{Emails: emails} + } + case "2", "k", "up": // Compose Email + m.state = composeView + m.composer = tui.NewComposer(m.fetcher.Config.Email) + return m, nil + } + } + + case inboxView: + switch msg := msg.(type) { + case tui.EmailsFetchedMsg: + m.emails = msg.Emails + m.inbox = tui.NewInbox(m.emails) + return m, nil + case tui.ViewEmailMsg: + m.state = emailView + m.emailView = tui.NewEmailView(m.emails[msg.Index]) + return m, nil + case tea.KeyMsg: + if msg.String() == "esc" { + m.state = mainMenu } } - // --- Custom Messages for switching views --- - case tui.GoToInboxMsg: - m.current = tui.NewInbox() - // Manually set the size of the new view - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) - - case tui.GoToSendMsg: - m.current = tui.NewComposer(m.config.Email) - m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) - - case tui.ViewEmailMsg: - m.current = tui.NewEmailView(msg.Email, m.width, m.height) - cmds = append(cmds, m.current.Init()) - - case tui.SendEmailMsg: - m.current = tui.NewStatus("Sending email...") - cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg)) - - case tui.EmailSentMsg: - m.current = tui.NewChoice() - cmds = append(cmds, m.current.Init()) + case emailView: + if msg, ok := msg.(tea.KeyMsg); ok && msg.String() == "esc" { + m.state = inboxView // Go back to the inbox list. + } + + case composeView: + switch msg := msg.(type) { + case tui.SendEmailMsg: + m.statusMessage = "⏳ Sending email..." + return m, func() tea.Msg { + err := m.sender.Send(msg.To, msg.Subject, msg.Body) + return tui.EmailResultMsg{Err: err} + } + case tea.KeyMsg: + if msg.String() == "esc" { + m.state = mainMenu + } + } } - // Pass all other messages to the current view - m.current, cmd = m.current.Update(msg) + // Pass the message to the current view's Update function. + switch m.state { + case loginView: + m.login, cmd = m.login.Update(msg) + case mainMenu: + m.mainMenu, cmd = m.mainMenu.Update(msg) + case inboxView: + m.inbox, cmd = m.inbox.Update(msg) + case emailView: + m.emailView, cmd = m.emailView.Update(msg) + case composeView: + m.composer, cmd = m.composer.Update(msg) + } cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } -func (m *mainModel) View() string { - return m.current.View() -} +// View renders the current UI. +func (m Model) View() string { + var s string + switch m.state { + case loginView: + s = m.login.View() + case mainMenu: + s = m.mainMenu.View() + case inboxView: + s = m.inbox.View() + case emailView: + s = m.emailView.View() + case composeView: + s = m.composer.View() + default: + s = "Unknown state." + } -// sendEmail is a command that sends an email in the background. -func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { - return func() tea.Msg { - recipients := []string{msg.To} - err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body) - if err != nil { - log.Printf("Failed to send email: %v", err) // Log error - } - time.Sleep(1 * time.Second) // Give user time to see the "Sending" message - return tui.EmailSentMsg{} + if m.statusMessage != "" { + return s + "\n\n" + m.statusMessage } + return s } func main() { - cfg, err := config.LoadConfig() - if err != nil { - log.Fatalf("could not load config: %v", err) + // Load config first to see if we can skip login. + cfg := config.Load() + m := New() + if cfg.Email != "" && cfg.Password != "" && cfg.Service != "" { + m.fetcher = fetcher.New(cfg) + m.sender = sender.New(cfg.Email, cfg.Password, cfg.Service) + m.state = mainMenu } - p := tea.NewProgram(newInitialModel(cfg), tea.WithAltScreen()) - + p := tea.NewProgram(m) if _, err := p.Run(); err != nil { - fmt.Printf("Alas, there's been an error: %v", err) + log.Fatal(err) os.Exit(1) } } \ No newline at end of file diff --git a/tui/choice.go b/tui/choice.go index 2d09abd287f7f0715af57cf3066c26a6a94412aa..9463323999e27a7abbb976ccf6fada374c1fac26 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -1,57 +1,70 @@ package tui import ( - "github.com/charmbracelet/bubbles/list" + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) -type Choice struct { - list list.Model -} - -func NewChoice() *Choice { - items := []list.Item{ - choiceItem("View Inbox"), - choiceItem("Send Email"), - } +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#7D56F4")). + PaddingLeft(2). + PaddingRight(2) - l := list.New(items, list.NewDefaultDelegate(), 0, 0) - l.Title = "What would you like to do?" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(false) + choiceStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + PaddingLeft(2) +) - return &Choice{list: l} +type Choice struct { + cursor int } -type choiceItem string - -func (i choiceItem) FilterValue() string { return "" } -func (i choiceItem) Title() string { return string(i) } -func (i choiceItem) Description() string { return "" } +func NewChoice() Choice { + return Choice{cursor: 0} +} -func (m *Choice) Init() tea.Cmd { +func (m Choice) Init() tea.Cmd { return nil } -func (m *Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd +func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.list.SetSize(msg.Width, msg.Height) case tea.KeyMsg: - if msg.String() == "enter" { - switch m.list.SelectedItem().(choiceItem) { - case "View Inbox": - return m, func() tea.Msg { return GoToInboxMsg{} } - case "Send Email": - return m, func() tea.Msg { return GoToSendMsg{} } + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < 1 { + m.cursor++ } } } - m.list, cmd = m.list.Update(msg) - return m, cmd + return m, nil } -func (m *Choice) View() string { - return DocStyle.Render(m.list.View()) +func (m Choice) View() string { + s := "What would you like to do?\n\n" + + choices := []string{"View Inbox", "Compose Email"} + + for i, choice := range choices { + cursor := " " + if m.cursor == i { + cursor = ">" + s += choiceStyle.Render(fmt.Sprintf("%s %s\n", cursor, choice)) + } else { + s += fmt.Sprintf("%s %s\n", cursor, choice) + } + } + + s += "\nPress q to quit.\n" + + return s } \ No newline at end of file diff --git a/tui/inbox.go b/tui/inbox.go index 5dc05ff0be662e6f2812142ea7f28641d7fa5137..cf46f70e05e9f70f4364c6ddc0f2f2e45f3af8b3 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -2,115 +2,103 @@ package tui import ( "fmt" + "io" - "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" ) -// inboxItem wraps fetcher.Email to satisfy the list.Item interface. -type inboxItem struct { - fetcher.Email +var ( + itemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205")) + paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + inboxHelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) +) + +type item struct { + title, desc string } -func (i inboxItem) Title() string { return i.Subject } -func (i inboxItem) Description() string { return fmt.Sprintf("From: %s", i.From) } -func (i inboxItem) FilterValue() string { return i.Subject } +func (i item) Title() string { return i.title } +func (i item) Description() string { return i.desc } +func (i item) FilterValue() string { return i.title + " " + i.desc } -type Inbox struct { - list list.Model - spinner spinner.Model - loading bool - err error -} +type itemDelegate struct{} -func NewInbox() *Inbox { - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } - // Create an empty list for now. It will be populated later. - l := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) - l.Title = "Inbox" + str := fmt.Sprintf("%d. %s", index+1, i.title) - return &Inbox{ - spinner: s, - list: l, - loading: true, + // **FIX**: Corrected the function literal to accept a variadic string + fn := itemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return selectedItemStyle.Render("> " + s[0]) + } } + + fmt.Fprint(w, fn(str)) } -func (m *Inbox) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, fetchEmails) +type Inbox struct { + list list.Model } -func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - switch msg := msg.(type) { - case spinner.TickMsg: - if m.loading { - m.spinner, cmd = m.spinner.Update(msg) +func NewInbox(emails []fetcher.Email) Inbox { + items := make([]list.Item, len(emails)) + for i, email := range emails { + items[i] = item{ + title: email.Subject, + desc: email.From, } - return m, cmd + } - case emailsFetchedMsg: - m.loading = false - items := make([]list.Item, len(msg)) - for i, email := range msg { - items[i] = inboxItem{email} - } - m.list.SetItems(items) - return m, nil + l := list.New(items, itemDelegate{}, 20, 14) + l.Title = "Inbox" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(false) + l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) + l.Styles.PaginationStyle = paginationStyle + l.Styles.HelpStyle = inboxHelpStyle - case errMsg: - m.err = msg.err - return m, tea.Quit + return Inbox{list: l} +} +func (m Inbox) Init() tea.Cmd { + return nil +} + +func (m Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { case tea.KeyMsg: - if msg.String() == "enter" && !m.loading { - selected := m.list.SelectedItem().(inboxItem) - return m, func() tea.Msg { - return ViewEmailMsg{Email: selected.Email} + if msg.String() == "enter" { + i, ok := m.list.SelectedItem().(item) + if ok { + _ = i + return m, func() tea.Msg { + return ViewEmailMsg{Index: m.list.Index()} + } } } - case tea.WindowSizeMsg: - // When the window size changes, update the list's dimensions. - m.list.SetSize(msg.Width, msg.Height) + m.list.SetWidth(msg.Width) + return m, nil } - if !m.loading { - m.list, cmd = m.list.Update(msg) - } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) return m, cmd } -func (m *Inbox) View() string { - if m.err != nil { - return fmt.Sprintf("Error: %v", m.err) - } - if m.loading { - return fmt.Sprintf("\n\n %s Fetching emails...\n\n", m.spinner.View()) - } - return DocStyle.Render(m.list.View()) -} - -// --- Bubble Tea Commands and Messages --- - -type emailsFetchedMsg []fetcher.Email -type errMsg struct{ err error } - -func fetchEmails() tea.Msg { - cfg, err := config.LoadConfig() - if err != nil { - return errMsg{err} - } - emails, err := fetcher.FetchEmails(cfg) - if err != nil { - return errMsg{err} - } - return emailsFetchedMsg(emails) +func (m Inbox) View() string { + return "\n" + m.list.View() } \ No newline at end of file diff --git a/tui/login.go b/tui/login.go index cca62b1cdce7ae0647de4d3b99f0408f40791f73..343ffcb71fcb0dddb6262371a25c2e064ebe6790 100644 --- a/tui/login.go +++ b/tui/login.go @@ -1,54 +1,38 @@ package tui import ( - "strings" - - "github.com/andrinoff/email-cli/config" - "github.com/charmbracelet/bubbles/cursor" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) -type loginModel struct { +// Login holds the state for the login form. +type Login struct { focusIndex int inputs []textinput.Model - cursorMode cursor.Mode - config *config.Config - err error } -func initialLoginModel() loginModel { - m := loginModel{ - inputs: make([]textinput.Model, 4), +// NewLogin creates a new login model. This function was missing. +func NewLogin() tea.Model { + m := Login{ + inputs: make([]textinput.Model, 2), } var t textinput.Model for i := range m.inputs { t = textinput.New() - t.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - t.CharLimit = 32 + t.Cursor.Style = focusedStyle + t.CharLimit = 64 switch i { case 0: - t.Placeholder = "gmail or icloud" + t.Placeholder = "Email" t.Focus() - t.Prompt = "> " - t.CharLimit = 10 + t.Prompt = "✉️ > " case 1: - t.Placeholder = "your@email.com" - t.Prompt = " " - t.CharLimit = 64 - case 2: - t.Placeholder = "App-specific password" - t.Prompt = " " + t.Placeholder = "Password" t.EchoMode = textinput.EchoPassword - t.EchoCharacter = '•' - t.CharLimit = 64 - case 3: - t.Placeholder = "Name" - t.Prompt = " " - t.CharLimit = 64 + t.Prompt = "🔑 > " } m.inputs[i] = t } @@ -56,92 +40,69 @@ func initialLoginModel() loginModel { return m } -func (m loginModel) Init() tea.Cmd { return textinput.Blink } +// Init initializes the login model. +func (m Login) Init() tea.Cmd { + return textinput.Blink +} -func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// Update handles messages for the login model. +func (m Login) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "ctrl+c", "esc": - return m, tea.Quit - case "tab", "shift+tab", "enter", "up", "down": - s := msg.String() - if s == "enter" && m.focusIndex == len(m.inputs) { - m.config = &config.Config{ - ServiceProvider: m.inputs[0].Value(), - Email: m.inputs[1].Value(), - Password: m.inputs[2].Value(), - Name: m.inputs[3].Value(), - } - if err := config.SaveConfig(m.config); err != nil { - m.err = err - return m, nil + switch msg.Type { + // On Enter, if we are on the last field, submit the credentials. + case tea.KeyEnter: + if m.focusIndex == len(m.inputs)-1 { + return m, func() tea.Msg { + return Credentials{ + Email: m.inputs[0].Value(), + Password: m.inputs[1].Value(), + } } - return m, tea.Quit } + fallthrough + // Cycle focus between inputs. + case tea.KeyTab, tea.KeyShiftTab, tea.KeyUp, tea.KeyDown: + s := msg.String() if s == "up" || s == "shift+tab" { m.focusIndex-- } else { m.focusIndex++ } - if m.focusIndex > len(m.inputs) { + + if m.focusIndex >= len(m.inputs) { m.focusIndex = 0 } else if m.focusIndex < 0 { - m.focusIndex = len(m.inputs) + m.focusIndex = len(m.inputs) - 1 } + cmds := make([]tea.Cmd, len(m.inputs)) for i := 0; i < len(m.inputs); i++ { if i == m.focusIndex { cmds[i] = m.inputs[i].Focus() - m.inputs[i].Prompt = "> " - continue + } else { + m.inputs[i].Blur() } - m.inputs[i].Blur() - m.inputs[i].Prompt = " " } return m, tea.Batch(cmds...) } } - cmd := m.updateInputs(msg) - return m, cmd -} -func (m *loginModel) updateInputs(msg tea.Msg) tea.Cmd { - cmds := make([]tea.Cmd, len(m.inputs)) + // Update the focused input field. + var cmds = make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } - return tea.Batch(cmds...) + return m, tea.Batch(cmds...) } -func (m loginModel) View() string { - var b strings.Builder - b.WriteString(InfoStyle.Render(" Welcome to email-cli! Please configure your email account. ") + "\n\n") - for i := range m.inputs { - b.WriteString(m.inputs[i].View()) - if i < len(m.inputs)-1 { - b.WriteRune('\n') - } - } - button := "\n\n" + "[ Submit ]" - if m.focusIndex == len(m.inputs) { - button = "\n\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Render("[ Submit ]") - } - b.WriteString(button) - if m.err != nil { - b.WriteString("\n\nError: " + m.err.Error()) - } - b.WriteString(HelpStyle.Render("\n\n(tab to navigate, enter to submit, esc to quit)")) - return DialogBoxStyle.Render(b.String()) -} - -// RunLogin starts the login UI and returns the created config. -func RunLogin() (*config.Config, error) { - p := tea.NewProgram(initialLoginModel()) - m, err := p.Run() - if err != nil { - return nil, err - } - finalModel := m.(loginModel) - return finalModel.config, nil +// View renders the login form. +func (m Login) View() string { + return lipgloss.JoinVertical(lipgloss.Left, + titleStyle.Render("Welcome to Email CLI"), + "Please enter your credentials.", + m.inputs[0].View(), + m.inputs[1].View(), + helpStyle.Render("\nenter: submit • tab: next field • esc: quit"), + ) } \ No newline at end of file diff --git a/tui/messages.go b/tui/messages.go index 071c256552de85791f78e3e3cfcdd8504434000a..9adac733b6fc910c24c1648f9f02d5921568bd43 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -2,23 +2,42 @@ package tui import "github.com/andrinoff/email-cli/fetcher" -// A message to tell the main model to switch to the inbox view. -type GoToInboxMsg struct{} - -// A message to tell the main model to switch to the composer view. -type GoToSendMsg struct{} - -// A message containing a fetched email to be viewed. +// A message to view an email. type ViewEmailMsg struct { - Email fetcher.Email + Index int } -// A message with the content of an email to be sent. +// A message to indicate that an email has been sent. type SendEmailMsg struct { To string Subject string Body string } -// A message to indicate that an email has been successfully sent. -type EmailSentMsg struct{} \ No newline at end of file +// A message to indicate that the user has entered their credentials. +type Credentials struct { + Email string + 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 \ No newline at end of file