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	tea "github.com/charmbracelet/bubbletea"
 15	"github.com/floatpane/matcha/config"
 16	"github.com/floatpane/matcha/fetcher"
 17	"github.com/floatpane/matcha/sender"
 18	"github.com/floatpane/matcha/tui"
 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	cachedComposer *tui.Composer // To cache a discarded draft
 33	config         *config.Config
 34	emails         []fetcher.Email
 35	inbox          *tui.Inbox
 36	width          int
 37	height         int
 38	err            error
 39}
 40
 41func newInitialModel(cfg *config.Config) *mainModel {
 42	// Determine if there is a cached composer to pass to the initial choice view
 43	hasCache := false
 44	initialModel := &mainModel{}
 45	if cfg == nil {
 46		initialModel.current = tui.NewLogin()
 47	} else {
 48		initialModel.current = tui.NewChoice(hasCache)
 49		initialModel.config = cfg
 50	}
 51	return initialModel
 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	m.current, cmd = m.current.Update(msg)
 63	cmds = append(cmds, cmd)
 64
 65	switch msg := msg.(type) {
 66	case tea.WindowSizeMsg:
 67		m.width = msg.Width
 68		m.height = msg.Height
 69		return m, nil
 70
 71	case tea.KeyMsg:
 72		if msg.String() == "ctrl+c" {
 73			return m, tea.Quit
 74		}
 75		if msg.String() == "esc" {
 76			switch m.current.(type) {
 77			case *tui.FilePicker:
 78				return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
 79			case *tui.Inbox, *tui.Login:
 80				m.current = tui.NewChoice(m.cachedComposer != nil)
 81				return m, m.current.Init()
 82			}
 83		}
 84
 85	case tui.BackToInboxMsg:
 86		if m.inbox != nil {
 87			m.current = m.inbox
 88		} else {
 89			m.current = tui.NewChoice(m.cachedComposer != nil)
 90		}
 91		return m, nil
 92
 93	case tui.DiscardDraftMsg:
 94		m.cachedComposer = msg.ComposerState
 95		m.current = tui.NewChoice(true) // Now there is a cached draft
 96		return m, m.current.Init()
 97
 98	case tui.RestoreDraftMsg:
 99		if m.cachedComposer != nil {
100			m.current = m.cachedComposer
101			m.cachedComposer.ResetConfirmation()
102			m.cachedComposer = nil // Clear cache after restoring
103			return m, m.current.Init()
104		}
105
106	case tui.Credentials:
107		cfg := &config.Config{
108			ServiceProvider: msg.Provider,
109			Name:            msg.Name,
110			Email:           msg.Email,
111			Password:        msg.Password,
112		}
113		if err := config.SaveConfig(cfg); err != nil {
114			log.Printf("could not save config: %v", err)
115			return m, tea.Quit
116		}
117		m.config = cfg
118		m.current = tui.NewChoice(m.cachedComposer != nil)
119		return m, m.current.Init()
120
121	case tui.GoToInboxMsg:
122		m.current = tui.NewStatus("Fetching emails...")
123		return m, tea.Batch(m.current.Init(), fetchEmails(m.config, initialEmailLimit, 0))
124
125	case tui.EmailsFetchedMsg:
126		m.emails = msg.Emails
127		m.inbox = tui.NewInbox(m.emails)
128		m.current = m.inbox
129		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
130		return m, m.current.Init()
131
132	case tui.FetchMoreEmailsMsg:
133		return m, tea.Batch(
134			func() tea.Msg { return tui.FetchingMoreEmailsMsg{} },
135			fetchEmails(m.config, paginationLimit, msg.Offset),
136		)
137
138	case tui.EmailsAppendedMsg:
139		m.emails = append(m.emails, msg.Emails...)
140		return m, nil
141
142	case tui.GoToSendMsg:
143		// When composing a new email, we discard any previously cached draft.
144		m.cachedComposer = nil
145		m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body)
146		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
147		return m, m.current.Init()
148
149	case tui.GoToSettingsMsg:
150		m.current = tui.NewLogin()
151		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
152		return m, m.current.Init()
153
154	case tui.ViewEmailMsg:
155		// Show a status message while fetching the email body
156		m.current = tui.NewStatus("Fetching email content...")
157		// Pass the index directly to the command
158		return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, m.emails[msg.Index], msg.Index))
159
160	case tui.EmailBodyFetchedMsg:
161		if msg.Err != nil {
162			log.Printf("could not fetch email body: %v", msg.Err)
163			m.current = m.inbox
164			return m, nil
165		}
166		// Use the index from the message to update the correct email
167		m.emails[msg.Index].Body = msg.Body
168		m.emails[msg.Index].Attachments = msg.Attachments
169
170		emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
171		m.current = emailView
172		return m, m.current.Init()
173
174	case tui.ReplyToEmailMsg:
175		to := msg.Email.From
176		subject := "Re: " + msg.Email.Subject
177		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> "))
178		m.current = tui.NewComposer(m.config.Email, to, subject, body)
179		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
180		return m, m.current.Init()
181
182	case tui.GoToFilePickerMsg:
183		m.previousModel = m.current
184		wd, _ := os.Getwd()
185		m.current = tui.NewFilePicker(wd)
186		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
187		return m, m.current.Init()
188
189	case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
190		if m.previousModel != nil {
191			m.current = m.previousModel
192			m.previousModel = nil
193		}
194		m.current, cmd = m.current.Update(msg)
195		cmds = append(cmds, cmd)
196
197	case tui.SendEmailMsg:
198		m.cachedComposer = nil // Clear cache on successful send
199		m.current = tui.NewStatus("Sending email...")
200		return m, tea.Batch(m.current.Init(), sendEmail(m.config, msg))
201
202	case tui.EmailResultMsg:
203		m.current = tui.NewChoice(m.cachedComposer != nil)
204		return m, m.current.Init()
205
206	case tui.DeleteEmailMsg:
207		m.previousModel = m.current
208		m.current = tui.NewStatus("Deleting email...")
209		return m, tea.Batch(m.current.Init(), deleteEmailCmd(m.config, msg.UID))
210
211	case tui.ArchiveEmailMsg:
212		m.previousModel = m.current
213		m.current = tui.NewStatus("Archiving email...")
214		return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID))
215
216	case tui.EmailActionDoneMsg:
217		if msg.Err != nil {
218			log.Printf("Action failed: %v", msg.Err)
219			m.current = m.inbox
220			return m, nil
221		}
222		var updatedEmails []fetcher.Email
223		for _, email := range m.emails {
224			if email.UID != msg.UID {
225				updatedEmails = append(updatedEmails, email)
226			}
227		}
228		m.emails = updatedEmails
229		m.inbox = tui.NewInbox(m.emails)
230		m.current = m.inbox
231		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
232		return m, m.current.Init()
233
234	case tui.DownloadAttachmentMsg:
235		m.previousModel = m.current
236		m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename))
237		// Use the new FetchAttachment function
238		return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(m.config, m.emails[msg.Index].UID, msg))
239
240	case tui.AttachmentDownloadedMsg:
241		var statusMsg string
242		if msg.Err != nil {
243			statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err)
244		} else {
245			statusMsg = fmt.Sprintf("Saved to %s", msg.Path)
246		}
247		m.current = tui.NewStatus(statusMsg)
248		return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
249			return tui.RestoreViewMsg{}
250		})
251
252	case tui.RestoreViewMsg:
253		if m.previousModel != nil {
254			m.current = m.previousModel
255			m.previousModel = nil
256		}
257		return m, nil
258	}
259
260	return m, tea.Batch(cmds...)
261}
262
263func (m *mainModel) View() string {
264	return m.current.View()
265}
266
267func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, index int) tea.Cmd {
268	return func() tea.Msg {
269		body, attachments, err := fetcher.FetchEmailBody(cfg, email.UID)
270		if err != nil {
271			return tui.EmailBodyFetchedMsg{Index: index, Err: err}
272		}
273
274		// Return the fetched data along with the original index
275		return tui.EmailBodyFetchedMsg{
276			Index:       index,
277			Body:        body,
278			Attachments: attachments,
279		}
280	}
281}
282
283func markdownToHTML(md []byte) []byte {
284	var buf bytes.Buffer
285	p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
286	if err := p.Convert(md, &buf); err != nil {
287		return md
288	}
289	return buf.Bytes()
290}
291
292func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
293	return func() tea.Msg {
294		recipients := []string{msg.To}
295		body := msg.Body
296		images := make(map[string][]byte)
297		attachments := make(map[string][]byte)
298
299		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
300		matches := re.FindAllStringSubmatch(body, -1)
301
302		for _, match := range matches {
303			imgPath := match[1]
304			imgData, err := os.ReadFile(imgPath)
305			if err != nil {
306				log.Printf("Could not read image file %s: %v", imgPath, err)
307				continue
308			}
309			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "matcha")
310			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
311			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
312		}
313
314		htmlBody := markdownToHTML([]byte(body))
315
316		if msg.AttachmentPath != "" {
317			fileData, err := os.ReadFile(msg.AttachmentPath)
318			if err != nil {
319				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
320			} else {
321				_, filename := filepath.Split(msg.AttachmentPath)
322				attachments[filename] = fileData
323			}
324		}
325
326		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References)
327		if err != nil {
328			log.Printf("Failed to send email: %v", err)
329			return tui.EmailResultMsg{Err: err}
330		}
331		return tui.EmailResultMsg{}
332	}
333}
334
335func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd {
336	return func() tea.Msg {
337		emails, err := fetcher.FetchEmails(cfg, limit, offset)
338		if err != nil {
339			return tui.FetchErr(err)
340		}
341		if offset == 0 {
342			return tui.EmailsFetchedMsg{Emails: emails}
343		}
344		return tui.EmailsAppendedMsg{Emails: emails}
345	}
346}
347
348func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
349	return func() tea.Msg {
350		err := fetcher.DeleteEmail(cfg, uid)
351		return tui.EmailActionDoneMsg{UID: uid, Err: err}
352	}
353}
354
355func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
356	return func() tea.Msg {
357		err := fetcher.ArchiveEmail(cfg, uid)
358		return tui.EmailActionDoneMsg{UID: uid, Err: err}
359	}
360}
361
362func downloadAttachmentCmd(cfg *config.Config, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd {
363	return func() tea.Msg {
364		data, err := fetcher.FetchAttachment(cfg, uid, msg.PartID)
365		if err != nil {
366			return tui.AttachmentDownloadedMsg{Err: err}
367		}
368
369		homeDir, err := os.UserHomeDir()
370		if err != nil {
371			return tui.AttachmentDownloadedMsg{Err: err}
372		}
373		downloadsPath := filepath.Join(homeDir, "Downloads")
374		if _, err := os.Stat(downloadsPath); os.IsNotExist(err) {
375			if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil {
376				return tui.AttachmentDownloadedMsg{Err: mkErr}
377			}
378		}
379		filePath := filepath.Join(downloadsPath, msg.Filename)
380		err = os.WriteFile(filePath, data, 0644)
381		return tui.AttachmentDownloadedMsg{Path: filePath, Err: err}
382	}
383}
384
385func main() {
386	cfg, err := config.LoadConfig()
387	var initialModel *mainModel
388	if err != nil {
389		initialModel = newInitialModel(nil)
390	} else {
391		initialModel = newInitialModel(cfg)
392	}
393
394	p := tea.NewProgram(initialModel, tea.WithAltScreen())
395
396	if _, err := p.Run(); err != nil {
397		fmt.Printf("Alas, there's been an error: %v", err)
398		os.Exit(1)
399	}
400}