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