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
 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}