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
29type mainModel struct {
30 current tea.Model
31 previousModel tea.Model
32 cachedComposer *tui.Composer // To cache a discarded draft
33 config *config.Config
34 emails []fetcher.Email
35 inbox *tui.Inbox
36 width int
37 height int
38 err error
39}
40
41func newInitialModel(cfg *config.Config) *mainModel {
42 // Determine if there is a cached composer to pass to the initial choice view
43 hasCache := false
44 initialModel := &mainModel{}
45 if cfg == nil {
46 initialModel.current = tui.NewLogin()
47 } else {
48 initialModel.current = tui.NewChoice(hasCache)
49 initialModel.config = cfg
50 }
51 return initialModel
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 m.current, cmd = m.current.Update(msg)
63 cmds = append(cmds, cmd)
64
65 switch msg := msg.(type) {
66 case tea.WindowSizeMsg:
67 m.width = msg.Width
68 m.height = msg.Height
69 return m, nil
70
71 case tea.KeyMsg:
72 if msg.String() == "ctrl+c" {
73 return m, tea.Quit
74 }
75 if msg.String() == "esc" {
76 switch m.current.(type) {
77 case *tui.FilePicker:
78 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
79 case *tui.Inbox, *tui.Login:
80 m.current = tui.NewChoice(m.cachedComposer != nil)
81 return m, m.current.Init()
82 }
83 }
84
85 case tui.BackToInboxMsg:
86 if m.inbox != nil {
87 m.current = m.inbox
88 } else {
89 m.current = tui.NewChoice(m.cachedComposer != nil)
90 }
91 return m, nil
92
93 case tui.DiscardDraftMsg:
94 m.cachedComposer = msg.ComposerState
95 m.current = tui.NewChoice(true) // Now there is a cached draft
96 return m, m.current.Init()
97
98 case tui.RestoreDraftMsg:
99 if m.cachedComposer != nil {
100 m.current = m.cachedComposer
101 m.cachedComposer.ResetConfirmation()
102 m.cachedComposer = nil // Clear cache after restoring
103 return m, m.current.Init()
104 }
105
106 case tui.Credentials:
107 cfg := &config.Config{
108 ServiceProvider: msg.Provider,
109 Name: msg.Name,
110 Email: msg.Email,
111 Password: msg.Password,
112 }
113 if err := config.SaveConfig(cfg); err != nil {
114 log.Printf("could not save config: %v", err)
115 return m, tea.Quit
116 }
117 m.config = cfg
118 m.current = tui.NewChoice(m.cachedComposer != nil)
119 return m, m.current.Init()
120
121 case tui.GoToInboxMsg:
122 m.current = tui.NewStatus("Fetching emails...")
123 return m, tea.Batch(m.current.Init(), fetchEmails(m.config, initialEmailLimit, 0))
124
125 case tui.EmailsFetchedMsg:
126 m.emails = msg.Emails
127 m.inbox = tui.NewInbox(m.emails)
128 m.current = m.inbox
129 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
130 return m, m.current.Init()
131
132 case tui.FetchMoreEmailsMsg:
133 return m, tea.Batch(
134 func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
135 fetchEmails(m.config, paginationLimit, msg.Offset),
136 )
137
138 case tui.EmailsAppendedMsg:
139 m.emails = append(m.emails, msg.Emails...)
140 return m, nil
141
142 case tui.GoToSendMsg:
143 // When composing a new email, we discard any previously cached draft.
144 m.cachedComposer = nil
145 m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body)
146 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
147 return m, m.current.Init()
148
149 case tui.GoToSettingsMsg:
150 m.current = tui.NewLogin()
151 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
152 return m, m.current.Init()
153
154 case tui.ViewEmailMsg:
155 // Show a status message while fetching the email body
156 m.current = tui.NewStatus("Fetching email content...")
157 // Pass the index directly to the command
158 return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, m.emails[msg.Index], msg.Index))
159
160 case tui.EmailBodyFetchedMsg:
161 if msg.Err != nil {
162 log.Printf("could not fetch email body: %v", msg.Err)
163 m.current = m.inbox
164 return m, nil
165 }
166 // Use the index from the message to update the correct email
167 m.emails[msg.Index].Body = msg.Body
168 m.emails[msg.Index].Attachments = msg.Attachments
169
170 emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
171 m.current = emailView
172 return m, m.current.Init()
173
174 case tui.ReplyToEmailMsg:
175 to := msg.Email.From
176 subject := "Re: " + msg.Email.Subject
177 body := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> "))
178 m.current = tui.NewComposer(m.config.Email, to, subject, body)
179 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
180 return m, m.current.Init()
181
182 case tui.GoToFilePickerMsg:
183 m.previousModel = m.current
184 wd, _ := os.Getwd()
185 m.current = tui.NewFilePicker(wd)
186 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
187 return m, m.current.Init()
188
189 case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
190 if m.previousModel != nil {
191 m.current = m.previousModel
192 m.previousModel = nil
193 }
194 m.current, cmd = m.current.Update(msg)
195 cmds = append(cmds, cmd)
196
197 case tui.SendEmailMsg:
198 m.cachedComposer = nil // Clear cache on successful send
199 m.current = tui.NewStatus("Sending email...")
200 return m, tea.Batch(m.current.Init(), sendEmail(m.config, msg))
201
202 case tui.EmailResultMsg:
203 m.current = tui.NewChoice(m.cachedComposer != nil)
204 return m, m.current.Init()
205
206 case tui.DeleteEmailMsg:
207 m.previousModel = m.current
208 m.current = tui.NewStatus("Deleting email...")
209 return m, tea.Batch(m.current.Init(), deleteEmailCmd(m.config, msg.UID))
210
211 case tui.ArchiveEmailMsg:
212 m.previousModel = m.current
213 m.current = tui.NewStatus("Archiving email...")
214 return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID))
215
216 case tui.EmailActionDoneMsg:
217 if msg.Err != nil {
218 log.Printf("Action failed: %v", msg.Err)
219 m.current = m.inbox
220 return m, nil
221 }
222 var updatedEmails []fetcher.Email
223 for _, email := range m.emails {
224 if email.UID != msg.UID {
225 updatedEmails = append(updatedEmails, email)
226 }
227 }
228 m.emails = updatedEmails
229 m.inbox = tui.NewInbox(m.emails)
230 m.current = m.inbox
231 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
232 return m, m.current.Init()
233
234 case tui.DownloadAttachmentMsg:
235 m.previousModel = m.current
236 m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
237 // Use the new FetchAttachment function
238 return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(m.config, m.emails[msg.Index].UID, msg))
239
240 case tui.AttachmentDownloadedMsg:
241 var statusMsg string
242 if msg.Err != nil {
243 statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
244 } else {
245 statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
246 }
247 m.current = tui.NewStatus(statusMsg)
248 return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
249 return tui.RestoreViewMsg{}
250 })
251
252 case tui.RestoreViewMsg:
253 if m.previousModel != nil {
254 m.current = m.previousModel
255 m.previousModel = nil
256 }
257 return m, nil
258 }
259
260 return m, tea.Batch(cmds...)
261}
262
263func (m *mainModel) View() string {
264 return m.current.View()
265}
266
267func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, index int) tea.Cmd {
268 return func() tea.Msg {
269 body, attachments, err := fetcher.FetchEmailBody(cfg, email.UID)
270 if err != nil {
271 return tui.EmailBodyFetchedMsg{Index: index, Err: err}
272 }
273
274 // Return the fetched data along with the original index
275 return tui.EmailBodyFetchedMsg{
276 Index: index,
277 Body: body,
278 Attachments: attachments,
279 }
280 }
281}
282
283
284func markdownToHTML(md []byte) []byte {
285 var buf bytes.Buffer
286 p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
287 if err := p.Convert(md, &buf); err != nil {
288 return md
289 }
290 return buf.Bytes()
291}
292
293func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
294 return func() tea.Msg {
295 recipients := []string{msg.To}
296 body := msg.Body
297 images := make(map[string][]byte)
298 attachments := make(map[string][]byte)
299
300 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
301 matches := re.FindAllStringSubmatch(body, -1)
302
303 for _, match := range matches {
304 imgPath := match[1]
305 imgData, err := os.ReadFile(imgPath)
306 if err != nil {
307 log.Printf("Could not read image file %s: %v", imgPath, err)
308 continue
309 }
310 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
311 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
312 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
313 }
314
315 htmlBody := markdownToHTML([]byte(body))
316
317 if msg.AttachmentPath != "" {
318 fileData, err := os.ReadFile(msg.AttachmentPath)
319 if err != nil {
320 log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
321 } else {
322 _, filename := filepath.Split(msg.AttachmentPath)
323 attachments[filename] = fileData
324 }
325 }
326
327 err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
328 if err != nil {
329 log.Printf("Failed to send email: %v", err)
330 return tui.EmailResultMsg{Err: err}
331 }
332 return tui.EmailResultMsg{}
333 }
334}
335
336func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
337 return func() tea.Msg {
338 emails, err := fetcher.FetchEmails(cfg, limit, offset)
339 if err != nil {
340 return tui.FetchErr(err)
341 }
342 if offset == 0 {
343 return tui.EmailsFetchedMsg{Emails: emails}
344 }
345 return tui.EmailsAppendedMsg{Emails: emails}
346 }
347}
348
349func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
350 return func() tea.Msg {
351 err := fetcher.DeleteEmail(cfg, uid)
352 return tui.EmailActionDoneMsg{UID: uid, Err: err}
353 }
354}
355
356func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
357 return func() tea.Msg {
358 err := fetcher.ArchiveEmail(cfg, uid)
359 return tui.EmailActionDoneMsg{UID: uid, Err: err}
360 }
361}
362
363func downloadAttachmentCmd(cfg *config.Config, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
364 return func() tea.Msg {
365 data, err := fetcher.FetchAttachment(cfg, uid, msg.PartID)
366 if err != nil {
367 return tui.AttachmentDownloadedMsg{Err: err}
368 }
369
370 homeDir, err := os.UserHomeDir()
371 if err != nil {
372 return tui.AttachmentDownloadedMsg{Err: err}
373 }
374 downloadsPath := filepath.Join(homeDir, "Downloads")
375 if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
376 if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
377 return tui.AttachmentDownloadedMsg{Err: mkErr}
378 }
379 }
380 filePath := filepath.Join(downloadsPath, msg.Filename)
381 err = os.WriteFile(filePath, data, 0644)
382 return tui.AttachmentDownloadedMsg{Path: filePath, Err: err}
383 }
384}
385
386func main() {
387 cfg, err := config.LoadConfig()
388 var initialModel *mainModel
389 if err != nil {
390 initialModel = newInitialModel(nil)
391 } else {
392 initialModel = newInitialModel(cfg)
393 }
394
395 p := tea.NewProgram(initialModel, tea.WithAltScreen())
396
397 if _, err := p.Run(); err != nil {
398 fmt.Printf("Alas, there's been an error: %v", err)
399 os.Exit(1)
400 }
401}