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
 24// mainModel holds the state for the entire application.
 25type mainModel struct {
 26	current tea.Model
 27	config  *config.Config
 28	emails  []fetcher.Email
 29	inbox   *tui.Inbox
 30	width   int
 31	height  int
 32	err     error
 33}
 34
 35// newInitialModel returns a pointer to the initial model.
 36func newInitialModel(cfg *config.Config) *mainModel {
 37	// If config is nil, start with the login screen.
 38	if cfg == nil {
 39		return &mainModel{
 40			current: tui.NewLogin(),
 41		}
 42	}
 43	return &mainModel{
 44		current: tui.NewChoice(),
 45		config:  cfg,
 46	}
 47}
 48
 49func (m *mainModel) Init() tea.Cmd {
 50	return m.current.Init()
 51}
 52
 53func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 54	var cmd tea.Cmd
 55	var cmds []tea.Cmd
 56
 57	switch msg := msg.(type) {
 58	case tea.WindowSizeMsg:
 59		m.width = msg.Width
 60		m.height = msg.Height
 61		// Pass the window size message to the current view.
 62		m.current, cmd = m.current.Update(msg)
 63		cmds = append(cmds, cmd)
 64		return m, tea.Batch(cmds...)
 65
 66	case tea.KeyMsg:
 67		if msg.String() == "ctrl+c" {
 68			return m, tea.Quit
 69		}
 70		// Allow ESC to navigate back.
 71		if msg.String() == "esc" {
 72			switch m.current.(type) {
 73			case *tui.EmailView:
 74				m.current = m.inbox // Go back to the cached inbox.
 75				return m, nil
 76			case *tui.Inbox, *tui.Composer, *tui.Login:
 77				m.current = tui.NewChoice() // Go back to the main menu.
 78				return m, m.current.Init()
 79			}
 80		}
 81
 82	// --- Custom Messages for Switching Views ---
 83	case tui.Credentials:
 84		cfg := &config.Config{
 85			ServiceProvider: msg.Provider,
 86			Name:            msg.Name,
 87			Email:           msg.Email,
 88			Password:        msg.Password,
 89		}
 90		if err := config.SaveConfig(cfg); err != nil {
 91			log.Printf("could not save config: %v", err)
 92			return m, tea.Quit
 93		}
 94		m.config = cfg
 95		m.current = tui.NewChoice()
 96		cmds = append(cmds, m.current.Init())
 97
 98	case tui.GoToInboxMsg:
 99		m.current = tui.NewStatus("Fetching emails...")
100		return m, tea.Batch(m.current.Init(), fetchEmails(m.config))
101
102	case tui.EmailsFetchedMsg:
103		m.emails = msg.Emails
104		m.inbox = tui.NewInbox(m.emails)
105		m.current = m.inbox
106		// Manually set the size of the new view.
107		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
108		cmds = append(cmds, m.current.Init())
109
110	case tui.GoToSendMsg:
111		m.current = tui.NewComposer(m.config.Email)
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.GoToSettingsMsg:
116		m.current = tui.NewLogin()
117		m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
118		cmds = append(cmds, m.current.Init())
119
120	case tui.ViewEmailMsg:
121		emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
122		m.current = emailView
123		cmds = append(cmds, m.current.Init())
124
125	case tui.SendEmailMsg:
126		m.current = tui.NewStatus("Sending email...")
127		cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg))
128
129	case tui.EmailResultMsg:
130		m.current = tui.NewChoice()
131		cmds = append(cmds, m.current.Init())
132	}
133
134	// Pass all other messages to the current view.
135	m.current, cmd = m.current.Update(msg)
136	cmds = append(cmds, cmd)
137
138	return m, tea.Batch(cmds...)
139}
140
141func (m *mainModel) View() string {
142	return m.current.View()
143}
144
145// markdownToHTML converts a Markdown string to an HTML string.
146func markdownToHTML(md []byte) []byte {
147	var buf bytes.Buffer
148	p := goldmark.New(
149		goldmark.WithRendererOptions(
150			html.WithUnsafe(), // Allow raw HTML in email.
151		),
152	)
153	if err := p.Convert(md, &buf); err != nil {
154		return md // Fallback to original markdown.
155	}
156	return buf.Bytes()
157}
158
159// sendEmail finds local image paths, embeds them, and sends the email.
160func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
161	return func() tea.Msg {
162		recipients := []string{msg.To}
163		body := msg.Body
164		images := make(map[string][]byte)
165
166		// Find all markdown image tags.
167		re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`)
168		matches := re.FindAllStringSubmatch(body, -1)
169
170		for _, match := range matches {
171			imgPath := match[1]
172			imgData, err := os.ReadFile(imgPath) // Use os.ReadFile.
173			if err != nil {
174				log.Printf("Could not read image file %s: %v", imgPath, err)
175				continue
176			}
177
178			// Create a unique CID that includes the file extension.
179			cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli")
180			images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData))
181			body = strings.Replace(body, imgPath, "cid:"+cid, 1)
182		}
183
184		htmlBody := markdownToHTML([]byte(body))
185
186		err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images)
187		if err != nil {
188			log.Printf("Failed to send email: %v", err)
189			return tui.EmailResultMsg{Err: err}
190		}
191		time.Sleep(1 * time.Second)
192		return tui.EmailResultMsg{}
193	}
194}
195
196// fetchEmails retrieves emails in the background.
197func fetchEmails(cfg *config.Config) tea.Cmd {
198	return func() tea.Msg {
199		emails, err := fetcher.FetchEmails(cfg)
200		if err != nil {
201			return tui.FetchErr(err)
202		}
203		return tui.EmailsFetchedMsg{Emails: emails}
204	}
205}
206
207func main() {
208	cfg, err := config.LoadConfig()
209	var initialModel *mainModel
210	if err != nil {
211		initialModel = newInitialModel(nil)
212	} else {
213		initialModel = newInitialModel(cfg)
214	}
215
216	p := tea.NewProgram(initialModel, tea.WithAltScreen())
217
218	if _, err := p.Run(); err != nil {
219		fmt.Printf("Alas, there's been an error: %v", err)
220		os.Exit(1)
221	}
222}