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, msg.To, msg.Subject, msg.Body)
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.ReplyToEmailMsg:
143 to := msg.Email.From
144 subject := "Re: " + msg.Email.Subject
145 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> "))
146 m.current = tui.NewComposer(m.config.Email, to, subject, body)
147 m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
148 cmds = append(cmds, m.current.Init())
149 // This is a reply, so we'll need to pass the message ID and references.
150 // We'll add this to the SendEmailMsg that gets created when the user hits send.
151 // We'll modify the composer so that when it creates a SendEmailMsg, it can be passed
152 // the InReplyTo and References.
153
154 case tui.SendEmailMsg:
155 m.current = tui.NewStatus("Sending email...")
156 cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
157
158 case tui.EmailResultMsg:
159 m.current = tui.NewChoice()
160 cmds = append(cmds, m.current.Init())
161 }
162
163 // Pass all other messages to the current view.
164 m.current, cmd = m.current.Update(msg)
165 cmds = append(cmds, cmd)
166
167 return m, tea.Batch(cmds...)
168}
169
170func (m *mainModel) View() string {
171 return m.current.View()
172}
173
174// markdownToHTML converts a Markdown string to an HTML string.
175func markdownToHTML(md []byte) []byte {
176 var buf bytes.Buffer
177 p := goldmark.New(
178 goldmark.WithRendererOptions(
179 html.WithUnsafe(), // Allow raw HTML in email.
180 ),
181 )
182 if err := p.Convert(md, &buf); err != nil {
183 return md // Fallback to original markdown.
184 }
185 return buf.Bytes()
186}
187
188// sendEmail finds local image paths, embeds them, and sends the email.
189func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
190 return func() tea.Msg {
191 recipients := []string{msg.To}
192 body := msg.Body
193 images := make(map[string][]byte)
194
195 // Find all markdown image tags.
196 re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
197 matches := re.FindAllStringSubmatch(body, -1)
198
199 for _, match := range matches {
200 imgPath := match[1]
201 imgData, err := os.ReadFile(imgPath) // Use os.ReadFile.
202 if err != nil {
203 log.Printf("Could not read image file %s: %v", imgPath, err)
204 continue
205 }
206
207 // Create a unique CID that includes the file extension.
208 cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
209 images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
210 body = strings.Replace(body, imgPath, "cid:"+cid, 1)
211 }
212
213 htmlBody := markdownToHTML([]byte(body))
214
215 err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, msg.InReplyTo, msg.References)
216 if err != nil {
217 log.Printf("Failed to send email: %v", err)
218 return tui.EmailResultMsg{Err: err}
219 }
220 time.Sleep(1 * time.Second)
221 return tui.EmailResultMsg{}
222 }
223}
224
225// fetchEmails retrieves emails in the background and dispatches the correct message.
226func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
227 return func() tea.Msg {
228 emails, err := fetcher.FetchEmails(cfg, limit, offset)
229 if err != nil {
230 return tui.FetchErr(err)
231 }
232 if offset == 0 {
233 // This is the initial fetch.
234 return tui.EmailsFetchedMsg{Emails: emails}
235 }
236 // This is a subsequent fetch for pagination.
237 return tui.EmailsAppendedMsg{Emails: emails}
238 }
239}
240
241func main() {
242 cfg, err := config.LoadConfig()
243 var initialModel *mainModel
244 if err != nil {
245 // If config doesn't exist, guide the user to create one via the login view.
246 initialModel = newInitialModel(nil)
247 } else {
248 initialModel = newInitialModel(cfg)
249 }
250
251 p := tea.NewProgram(initialModel, tea.WithAltScreen())
252
253 if _, err := p.Run(); err != nil {
254 fmt.Printf("Alas, there's been an error: %v", err)
255 os.Exit(1)
256 }
257}