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