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 previousModel tea.Model // To store the view before opening the file picker
33 config *config.Config
34 emails []fetcher.Email
35 inbox *tui.Inbox
36 width int
37 height int
38 err error
39}
40
41// newInitialModel returns a pointer to the initial model.
42func newInitialModel(cfg *config.Config) *mainModel {
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 m.current, cmd = m.current.Update(msg)
67 cmds = append(cmds, cmd)
68 return m, tea.Batch(cmds...)
69
70 case tea.KeyMsg:
71 if msg.String() == "ctrl+c" {
72 return m, tea.Quit
73 }
74 if msg.String() == "esc" {
75 // If we are in a nested view like email or file picker, handle it here
76 switch m.current.(type) {
77 case *tui.EmailView:
78 m.current = m.inbox
79 return m, nil
80 case *tui.FilePicker:
81 return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
82 case *tui.Inbox, *tui.Composer, *tui.Login:
83 m.current = tui.NewChoice()
84 return m, m.current.Init()
85 }
86 }
87
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 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
112 cmds = append(cmds, m.current.Init())
113
114 case tui.FetchMoreEmailsMsg:
115 cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} })
116 cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset))
117 return m, tea.Batch(cmds...)
118
119 case tui.EmailsAppendedMsg:
120 m.emails = append(m.emails, msg.Emails...)
121 m.current, cmd = m.current.Update(msg)
122 cmds = append(cmds, cmd)
123 return m, tea.Batch(cmds...)
124
125 case tui.GoToSendMsg:
126 m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body)
127 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
128 cmds = append(cmds, m.current.Init())
129
130 case tui.GoToSettingsMsg:
131 m.current = tui.NewLogin()
132 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
133 cmds = append(cmds, m.current.Init())
134
135 case tui.ViewEmailMsg:
136 emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
137 m.current = emailView
138 cmds = append(cmds, m.current.Init())
139
140 case tui.ReplyToEmailMsg:
141 to := msg.Email.From
142 subject := "Re: " + msg.Email.Subject
143 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> "))
144 m.current = tui.NewComposer(m.config.Email, to, subject, body)
145 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
146 cmds = append(cmds, m.current.Init())
147
148 case tui.GoToFilePickerMsg:
149 m.previousModel = m.current
150 wd, _ := os.Getwd()
151 m.current = tui.NewFilePicker(wd)
152 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
153 return m, m.current.Init()
154
155 case tui.FileSelectedMsg:
156 if m.previousModel != nil {
157 // Send the selection message to the previous model (the composer)
158 m.previousModel, cmd = m.previousModel.Update(msg)
159 cmds = append(cmds, cmd)
160 // Return to the composer view
161 m.current = m.previousModel
162 m.previousModel = nil
163 }
164 return m, tea.Batch(cmds...)
165
166 case tui.CancelFilePickerMsg:
167 if m.previousModel != nil {
168 m.current = m.previousModel
169 m.previousModel = nil
170 }
171 return m, nil
172
173 case tui.SendEmailMsg:
174 m.current = tui.NewStatus("Sending email...")
175 cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
176
177 case tui.EmailResultMsg:
178 m.current = tui.NewChoice()
179 cmds = append(cmds, m.current.Init())
180 }
181
182 m.current, cmd = m.current.Update(msg)
183 cmds = append(cmds, cmd)
184
185 return m, tea.Batch(cmds...)
186}
187
188func (m *mainModel) View() string {
189 return m.current.View()
190}
191
192func markdownToHTML(md []byte) []byte {
193 var buf bytes.Buffer
194 p := goldmark.New(
195 goldmark.WithRendererOptions(
196 html.WithUnsafe(),
197 ),
198 )
199 if err := p.Convert(md, &buf); err != nil {
200 return md
201 }
202 return buf.Bytes()
203}
204
205func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
206 return func() tea.Msg {
207 recipients := []string{msg.To}
208 body := msg.Body
209 images := make(map[string][]byte)
210 attachments := make(map[string][]byte)
211
212 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
213 matches := re.FindAllStringSubmatch(body, -1)
214
215 for _, match := range matches {
216 imgPath := match[1]
217 imgData, err := os.ReadFile(imgPath)
218 if err != nil {
219 log.Printf("Could not read image file %s: %v", imgPath, err)
220 continue
221 }
222 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
223 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
224 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
225 }
226
227 htmlBody := markdownToHTML([]byte(body))
228
229 if msg.AttachmentPath != "" {
230 fileData, err := os.ReadFile(msg.AttachmentPath)
231 if err != nil {
232 log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
233 } else {
234 _, filename := filepath.Split(msg.AttachmentPath)
235 attachments[filename] = fileData
236 }
237 }
238
239 err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
240 if err != nil {
241 log.Printf("Failed to send email: %v", err)
242 return tui.EmailResultMsg{Err: err}
243 }
244 time.Sleep(1 * time.Second)
245 return tui.EmailResultMsg{}
246 }
247}
248
249func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
250 return func() tea.Msg {
251 emails, err := fetcher.FetchEmails(cfg, limit, offset)
252 if err != nil {
253 return tui.FetchErr(err)
254 }
255 if offset == 0 {
256 return tui.EmailsFetchedMsg{Emails: emails}
257 }
258 return tui.EmailsAppendedMsg{Emails: emails}
259 }
260}
261
262func main() {
263 cfg, err := config.LoadConfig()
264 var initialModel *mainModel
265 if err != nil {
266 initialModel = newInitialModel(nil)
267 } else {
268 initialModel = newInitialModel(cfg)
269 }
270
271 p := tea.NewProgram(initialModel, tea.WithAltScreen())
272
273 if _, err := p.Run(); err != nil {
274 fmt.Printf("Alas, there's been an error: %v", err)
275 os.Exit(1)
276 }
277}