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 config *config.Config
33 emails []fetcher.Email
34 inbox *tui.Inbox
35 width int
36 height int
37 err error
38}
39
40func newInitialModel(cfg *config.Config) *mainModel {
41 if cfg == nil {
42 return &mainModel{
43 current: tui.NewLogin(),
44 }
45 }
46 return &mainModel{
47 current: tui.NewChoice(),
48 config: cfg,
49 }
50}
51
52func (m *mainModel) Init() tea.Cmd {
53 return m.current.Init()
54}
55
56func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57 var cmd tea.Cmd
58 var cmds []tea.Cmd
59
60 switch msg := msg.(type) {
61 case tea.WindowSizeMsg:
62 m.width = msg.Width
63 m.height = msg.Height
64 m.current, cmd = m.current.Update(msg)
65 cmds = append(cmds, cmd)
66 return m, tea.Batch(cmds...)
67
68 case tea.KeyMsg:
69 if msg.String() == "ctrl+c" {
70 return m, tea.Quit
71 }
72 if msg.String() == "esc" {
73 switch m.current.(type) {
74 case *tui.EmailView:
75 m.current = m.inbox
76 return m, nil
77 case *tui.FilePicker:
78 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
79 case *tui.Inbox, *tui.Composer, *tui.Login:
80 m.current = tui.NewChoice()
81 return m, m.current.Init()
82 }
83 }
84
85 case tui.Credentials:
86 cfg := &config.Config{
87 ServiceProvider: msg.Provider,
88 Name: msg.Name,
89 Email: msg.Email,
90 Password: msg.Password,
91 }
92 if err := config.SaveConfig(cfg); err != nil {
93 log.Printf("could not save config: %v", err)
94 return m, tea.Quit
95 }
96 m.config = cfg
97 m.current = tui.NewChoice()
98 cmds = append(cmds, m.current.Init())
99
100 case tui.GoToInboxMsg:
101 m.current = tui.NewStatus("Fetching emails...")
102 return m, tea.Batch(m.current.Init(), fetchEmails(m.config, initialEmailLimit, 0))
103
104 case tui.EmailsFetchedMsg:
105 m.emails = msg.Emails
106 m.inbox = tui.NewInbox(m.emails)
107 m.current = m.inbox
108 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
109 cmds = append(cmds, m.current.Init())
110
111 case tui.FetchMoreEmailsMsg:
112 cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} })
113 cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset))
114 return m, tea.Batch(cmds...)
115
116 case tui.EmailsAppendedMsg:
117 m.emails = append(m.emails, msg.Emails...)
118 m.current, cmd = m.current.Update(msg)
119 cmds = append(cmds, cmd)
120 return m, tea.Batch(cmds...)
121
122 case tui.GoToSendMsg:
123 m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body)
124 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
125 cmds = append(cmds, m.current.Init())
126
127 case tui.GoToSettingsMsg:
128 m.current = tui.NewLogin()
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.ViewEmailMsg:
133 emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
134 m.current = emailView
135 cmds = append(cmds, m.current.Init())
136
137 case tui.ReplyToEmailMsg:
138 to := msg.Email.From
139 subject := "Re: " + msg.Email.Subject
140 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> "))
141 m.current = tui.NewComposer(m.config.Email, to, subject, body)
142 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
143 cmds = append(cmds, m.current.Init())
144
145 case tui.GoToFilePickerMsg:
146 m.previousModel = m.current
147 wd, _ := os.Getwd()
148 m.current = tui.NewFilePicker(wd)
149 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
150 return m, m.current.Init()
151
152 case tui.FileSelectedMsg:
153 if m.previousModel != nil {
154 m.previousModel, cmd = m.previousModel.Update(msg)
155 cmds = append(cmds, cmd)
156 m.current = m.previousModel
157 m.previousModel = nil
158 }
159 return m, tea.Batch(cmds...)
160
161 case tui.CancelFilePickerMsg:
162 if m.previousModel != nil {
163 m.current = m.previousModel
164 m.previousModel = nil
165 }
166 return m, nil
167
168 case tui.SendEmailMsg:
169 m.current = tui.NewStatus("Sending email...")
170 cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
171
172 case tui.EmailResultMsg:
173 m.current = tui.NewChoice()
174 cmds = append(cmds, m.current.Init())
175
176 case tui.DeleteEmailMsg:
177 m.current = tui.NewStatus("Deleting email...")
178 cmds = append(cmds, m.current.Init(), deleteEmailCmd(m.config, msg.UID))
179
180 case tui.ArchiveEmailMsg:
181 m.current = tui.NewStatus("Archiving email...")
182 cmds = append(cmds, m.current.Init(), archiveEmailCmd(m.config, msg.UID))
183
184 case tui.EmailActionDoneMsg:
185 if msg.Err != nil {
186 // In a real app, you might show an error message.
187 // For now, we'll just go back to the inbox.
188 log.Printf("Action failed: %v", msg.Err)
189 m.current = m.inbox
190 return m, nil
191 }
192 // Remove the email from the local cache
193 var updatedEmails []fetcher.Email
194 for _, email := range m.emails {
195 if email.UID != msg.UID {
196 updatedEmails = append(updatedEmails, email)
197 }
198 }
199 m.emails = updatedEmails
200 // Refresh the inbox view
201 m.inbox = tui.NewInbox(m.emails)
202 m.current = m.inbox
203 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
204 return m, m.current.Init()
205 }
206
207 m.current, cmd = m.current.Update(msg)
208 cmds = append(cmds, cmd)
209
210 return m, tea.Batch(cmds...)
211}
212
213func (m *mainModel) View() string {
214 return m.current.View()
215}
216
217func markdownToHTML(md []byte) []byte {
218 var buf bytes.Buffer
219 p := goldmark.New(
220 goldmark.WithRendererOptions(
221 html.WithUnsafe(),
222 ),
223 )
224 if err := p.Convert(md, &buf); err != nil {
225 return md
226 }
227 return buf.Bytes()
228}
229
230func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
231 return func() tea.Msg {
232 recipients := []string{msg.To}
233 body := msg.Body
234 images := make(map[string][]byte)
235 attachments := make(map[string][]byte)
236
237 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
238 matches := re.FindAllStringSubmatch(body, -1)
239
240 for _, match := range matches {
241 imgPath := match[1]
242 imgData, err := os.ReadFile(imgPath)
243 if err != nil {
244 log.Printf("Could not read image file %s: %v", imgPath, err)
245 continue
246 }
247 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
248 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
249 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
250 }
251
252 htmlBody := markdownToHTML([]byte(body))
253
254 if msg.AttachmentPath != "" {
255 fileData, err := os.ReadFile(msg.AttachmentPath)
256 if err != nil {
257 log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
258 } else {
259 _, filename := filepath.Split(msg.AttachmentPath)
260 attachments[filename] = fileData
261 }
262 }
263
264 err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
265 if err != nil {
266 log.Printf("Failed to send email: %v", err)
267 return tui.EmailResultMsg{Err: err}
268 }
269 time.Sleep(1 * time.Second)
270 return tui.EmailResultMsg{}
271 }
272}
273
274func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
275 return func() tea.Msg {
276 emails, err := fetcher.FetchEmails(cfg, limit, offset)
277 if err != nil {
278 return tui.FetchErr(err)
279 }
280 if offset == 0 {
281 return tui.EmailsFetchedMsg{Emails: emails}
282 }
283 return tui.EmailsAppendedMsg{Emails: emails}
284 }
285}
286
287func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
288 return func() tea.Msg {
289 err := fetcher.DeleteEmail(cfg, uid)
290 return tui.EmailActionDoneMsg{UID: uid, Err: err}
291 }
292}
293
294func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
295 return func() tea.Msg {
296 err := fetcher.ArchiveEmail(cfg, uid)
297 return tui.EmailActionDoneMsg{UID: uid, Err: err}
298 }
299}
300
301func main() {
302 cfg, err := config.LoadConfig()
303 var initialModel *mainModel
304 if err != nil {
305 initialModel = newInitialModel(nil)
306 } else {
307 initialModel = newInitialModel(cfg)
308 }
309
310 p := tea.NewProgram(initialModel, tea.WithAltScreen())
311
312 if _, err := p.Run(); err != nil {
313 fmt.Printf("Alas, there's been an error: %v", err)
314 os.Exit(1)
315 }
316}