main.go

  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}