1package main
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "log"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "time"
13
14 "github.com/andrinoff/email-cli/config"
15 "github.com/andrinoff/email-cli/fetcher"
16 "github.com/andrinoff/email-cli/sender"
17 "github.com/andrinoff/email-cli/tui"
18 tea "github.com/charmbracelet/bubbletea"
19 "github.com/google/uuid"
20 "github.com/yuin/goldmark"
21 "github.com/yuin/goldmark/renderer/html"
22)
23
24const (
25 initialEmailLimit = 20
26 paginationLimit = 20
27)
28
29// mainModel holds the state for the entire application.
30type mainModel struct {
31 current tea.Model
32 config *config.Config
33 emails []fetcher.Email
34 inbox *tui.Inbox
35 width int
36 height int
37 err error
38}
39
40// newInitialModel returns a pointer to the initial model.
41func newInitialModel(cfg *config.Config) *mainModel {
42 // If config is nil, start with the login screen.
43 if cfg == nil {
44 return &mainModel{
45 current: tui.NewLogin(),
46 }
47 }
48 return &mainModel{
49 current: tui.NewChoice(),
50 config: cfg,
51 }
52}
53
54func (m *mainModel) Init() tea.Cmd {
55 return m.current.Init()
56}
57
58func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 var cmd tea.Cmd
60 var cmds []tea.Cmd
61
62 switch msg := msg.(type) {
63 case tea.WindowSizeMsg:
64 m.width = msg.Width
65 m.height = msg.Height
66 // Pass the window size message to the current view.
67 m.current, cmd = m.current.Update(msg)
68 cmds = append(cmds, cmd)
69 return m, tea.Batch(cmds...)
70
71 case tea.KeyMsg:
72 if msg.String() == "ctrl+c" {
73 return m, tea.Quit
74 }
75 // Allow ESC to navigate back.
76 if msg.String() == "esc" {
77 switch m.current.(type) {
78 case *tui.EmailView:
79 m.current = m.inbox // Go back to the cached inbox.
80 return m, nil
81 case *tui.Inbox, *tui.Composer, *tui.Login:
82 m.current = tui.NewChoice() // Go back to the main menu.
83 return m, m.current.Init()
84 }
85 }
86
87 // --- Custom Messages for Switching Views and Pagination ---
88 case tui.Credentials:
89 cfg := &config.Config{
90 ServiceProvider: msg.Provider,
91 Name: msg.Name,
92 Email: msg.Email,
93 Password: msg.Password,
94 }
95 if err := config.SaveConfig(cfg); err != nil {
96 log.Printf("could not save config: %v", err)
97 return m, tea.Quit
98 }
99 m.config = cfg
100 m.current = tui.NewChoice()
101 cmds = append(cmds, m.current.Init())
102
103 case tui.GoToInboxMsg:
104 m.current = tui.NewStatus("Fetching emails...")
105 return m, tea.Batch(m.current.Init(), fetchEmails(m.config, initialEmailLimit, 0))
106
107 case tui.EmailsFetchedMsg:
108 m.emails = msg.Emails
109 m.inbox = tui.NewInbox(m.emails)
110 m.current = m.inbox
111 // Manually set the size of the new view.
112 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
113 cmds = append(cmds, m.current.Init())
114
115 case tui.FetchMoreEmailsMsg:
116 cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} })
117 cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset))
118 return m, tea.Batch(cmds...)
119
120 case tui.EmailsAppendedMsg:
121 m.emails = append(m.emails, msg.Emails...)
122 // Pass the new emails to the inbox to be appended.
123 m.current, cmd = m.current.Update(msg)
124 cmds = append(cmds, cmd)
125 return m, tea.Batch(cmds...)
126
127 case tui.GoToSendMsg:
128 m.current = tui.NewComposer(m.config.Email)
129 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
130 cmds = append(cmds, m.current.Init())
131
132 case tui.GoToSettingsMsg:
133 m.current = tui.NewLogin()
134 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
135 cmds = append(cmds, m.current.Init())
136
137 case tui.ViewEmailMsg:
138 emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
139 m.current = emailView
140 cmds = append(cmds, m.current.Init())
141
142 case tui.SendEmailMsg:
143 m.current = tui.NewStatus("Sending email...")
144 cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
145
146 case tui.EmailResultMsg:
147 m.current = tui.NewChoice()
148 cmds = append(cmds, m.current.Init())
149 }
150
151 // Pass all other messages to the current view.
152 m.current, cmd = m.current.Update(msg)
153 cmds = append(cmds, cmd)
154
155 return m, tea.Batch(cmds...)
156}
157
158func (m *mainModel) View() string {
159 return m.current.View()
160}
161
162// markdownToHTML converts a Markdown string to an HTML string.
163func markdownToHTML(md []byte) []byte {
164 var buf bytes.Buffer
165 p := goldmark.New(
166 goldmark.WithRendererOptions(
167 html.WithUnsafe(), // Allow raw HTML in email.
168 ),
169 )
170 if err := p.Convert(md, &buf); err != nil {
171 return md // Fallback to original markdown.
172 }
173 return buf.Bytes()
174}
175
176// sendEmail finds local image paths, embeds them, and sends the email.
177func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
178 return func() tea.Msg {
179 recipients := []string{msg.To}
180 body := msg.Body
181 images := make(map[string][]byte)
182
183 // Find all markdown image tags.
184 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
185 matches := re.FindAllStringSubmatch(body, -1)
186
187 for _, match := range matches {
188 imgPath := match[1]
189 imgData, err := os.ReadFile(imgPath) // Use os.ReadFile.
190 if err != nil {
191 log.Printf("Could not read image file %s: %v", imgPath, err)
192 continue
193 }
194
195 // Create a unique CID that includes the file extension.
196 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
197 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
198 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
199 }
200
201 htmlBody := markdownToHTML([]byte(body))
202
203 err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images)
204 if err != nil {
205 log.Printf("Failed to send email: %v", err)
206 return tui.EmailResultMsg{Err: err}
207 }
208 time.Sleep(1 * time.Second)
209 return tui.EmailResultMsg{}
210 }
211}
212
213// fetchEmails retrieves emails in the background and dispatches the correct message.
214func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
215 return func() tea.Msg {
216 emails, err := fetcher.FetchEmails(cfg, limit, offset)
217 if err != nil {
218 return tui.FetchErr(err)
219 }
220 if offset == 0 {
221 // This is the initial fetch.
222 return tui.EmailsFetchedMsg{Emails: emails}
223 }
224 // This is a subsequent fetch for pagination.
225 return tui.EmailsAppendedMsg{Emails: emails}
226 }
227}
228
229func main() {
230 cfg, err := config.LoadConfig()
231 var initialModel *mainModel
232 if err != nil {
233 // If config doesn't exist, guide the user to create one via the login view.
234 initialModel = newInitialModel(nil)
235 } else {
236 initialModel = newInitialModel(cfg)
237 }
238
239 p := tea.NewProgram(initialModel, tea.WithAltScreen())
240
241 if _, err := p.Run(); err != nil {
242 fmt.Printf("Alas, there's been an error: %v", err)
243 os.Exit(1)
244 }
245}