diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index c7c9ee4d4e2597b5598ed9adc047e05f4ee99870..8b3683ac2d4674930e3e4e3c960d4d0a1c4b9153 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1,6 +1,7 @@ package fetcher import ( + "encoding/base64" "fmt" "io" "io/ioutil" @@ -17,15 +18,24 @@ import ( "golang.org/x/text/transform" ) +// Attachment holds data for an email attachment. +type Attachment struct { + Filename string + Data []byte +} + type Email struct { - From string - To []string - Subject string - Body string - Date time.Time + UID uint32 + From string + To []string + Subject string + Body string + Date time.Time + MessageID string + References []string + Attachments []Attachment } -// ... (decodePart and decodeHeader functions remain the same) func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type")) if err != nil { @@ -33,7 +43,7 @@ func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { return string(body), nil } - charset := "utf-8" // Default charset + charset := "utf-8" if params["charset"] != "" { charset = strings.ToLower(params["charset"]) } @@ -72,8 +82,7 @@ func decodeHeader(header string) string { return decoded } -// FetchEmails now supports pagination with a limit and offset. -func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { +func connect(cfg *config.Config) (*client.Client, error) { var imapServer string var imapPort int @@ -85,7 +94,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { imapServer = "imap.mail.me.com" imapPort = 993 default: - return nil, fmt.Errorf("unsupported or missing service_provider in config.json: %s", cfg.ServiceProvider) + return nil, fmt.Errorf("unsupported service_provider: %s", cfg.ServiceProvider) } addr := fmt.Sprintf("%s:%d", imapServer, imapPort) @@ -93,20 +102,26 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { if err != nil { return nil, err } - log.Println("Connected") - defer c.Logout() if err := c.Login(cfg.Email, cfg.Password); err != nil { return nil, err } - log.Println("Logged in") + + return c, nil +} + +func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { + c, err := connect(cfg) + if err != nil { + return nil, err + } + defer c.Logout() mbox, err := c.Select("INBOX", false) if err != nil { return nil, err } - // Handle pagination logic if mbox.Messages == 0 { return []Email{}, nil } @@ -118,7 +133,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } if to < 1 { - return []Email{}, nil // No more messages to fetch + return []Email{}, nil } seqset := new(imap.SeqSet) @@ -126,8 +141,9 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { messages := make(chan *imap.Message, limit) done := make(chan error, 1) + fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchItem("BODY[]")} go func() { - done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchItem("BODY[]")}, messages) + done <- c.Fetch(seqset, fetchItems, messages) }() var emails []Email @@ -153,6 +169,8 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { toAddrs, _ := header.AddressList("To") subject := decodeHeader(header.Get("Subject")) date, _ := header.Date() + messageID := header.Get("Message-ID") + references := header.Get("References") var fromAddr string if len(fromAddrs) > 0 { @@ -165,6 +183,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } var body string + var attachments []Attachment for { p, err := mr.NextPart() if err == io.EOF { @@ -174,22 +193,47 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { break } + // Correctly parse Content-Disposition + cdHeader := p.Header.Get("Content-Disposition") + if cdHeader != "" { + disposition, params, err := mime.ParseMediaType(cdHeader) + if err == nil && (disposition == "attachment" || disposition == "inline") { + filename := params["filename"] + if filename != "" { + partBody, _ := ioutil.ReadAll(p.Body) + encoding := p.Header.Get("Content-Transfer-Encoding") + if strings.ToLower(encoding) == "base64" { + decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody)) + if decodeErr == nil { + partBody = decoded + } + } + attachments = append(attachments, Attachment{Filename: filename, Data: partBody}) + continue // Skip to next part + } + } + } + + // Process body part if not an attachment mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) - if mediaType == "text/plain" || mediaType == "text/html" { + if (mediaType == "text/plain" || mediaType == "text/html") && body == "" { decodedPart, decodeErr := decodePart(p.Body, p.Header) if decodeErr == nil { body = decodedPart - break } } } emails = append(emails, Email{ - From: fromAddr, - To: toAddrList, - Subject: subject, - Body: body, - Date: date, + UID: msg.Uid, + From: fromAddr, + To: toAddrList, + Subject: subject, + Body: body, + Date: date, + MessageID: messageID, + References: strings.Fields(references), + Attachments: attachments, }) } @@ -202,4 +246,43 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } return emails, nil +} + +func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { + c, err := connect(cfg) + if err != nil { + return err + } + defer c.Logout() + + if _, err := c.Select("INBOX", false); err != nil { + return err + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + return c.UidMove(seqSet, destMailbox) +} + +func DeleteEmail(cfg *config.Config, uid uint32) error { + var trashMailbox string + switch cfg.ServiceProvider { + case "gmail": + trashMailbox = "[Gmail]/Trash" + default: + trashMailbox = "Trash" + } + return moveEmail(cfg, uid, trashMailbox) +} + +func ArchiveEmail(cfg *config.Config, uid uint32) error { + var archiveMailbox string + switch cfg.ServiceProvider { + case "gmail": + archiveMailbox = "[Gmail]/All Mail" + default: + archiveMailbox = "Archive" + } + return moveEmail(cfg, uid, archiveMailbox) } \ No newline at end of file diff --git a/main.go b/main.go index a01562b1f913c89245e8af1f86e383ddd2153936..db47509032aabfa7c0bb18877aa782271a50921d 100644 --- a/main.go +++ b/main.go @@ -26,29 +26,29 @@ const ( paginationLimit = 20 ) -// mainModel holds the state for the entire application. type mainModel struct { - current tea.Model - config *config.Config - emails []fetcher.Email - inbox *tui.Inbox - width int - height int - err error + current tea.Model + previousModel tea.Model + cachedComposer *tui.Composer // To cache a discarded draft + config *config.Config + emails []fetcher.Email + inbox *tui.Inbox + width int + height int + err error } -// newInitialModel returns a pointer to the initial model. func newInitialModel(cfg *config.Config) *mainModel { - // If config is nil, start with the login screen. + // Determine if there is a cached composer to pass to the initial choice view + hasCache := false + initialModel := &mainModel{} if cfg == nil { - return &mainModel{ - current: tui.NewLogin(), - } - } - return &mainModel{ - current: tui.NewChoice(), - config: cfg, + initialModel.current = tui.NewLogin() + } else { + initialModel.current = tui.NewChoice(hasCache) + initialModel.config = cfg } + return initialModel } func (m *mainModel) Init() tea.Cmd { @@ -59,32 +59,50 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + m.current, cmd = m.current.Update(msg) + cmds = append(cmds, cmd) + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - // Pass the window size message to the current view. - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + return m, nil case tea.KeyMsg: if msg.String() == "ctrl+c" { return m, tea.Quit } - // Allow ESC to navigate back. if msg.String() == "esc" { switch m.current.(type) { - case *tui.EmailView: - m.current = m.inbox // Go back to the cached inbox. - return m, nil - case *tui.Inbox, *tui.Composer, *tui.Login: - m.current = tui.NewChoice() // Go back to the main menu. + case *tui.FilePicker: + return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } + case *tui.Inbox, *tui.Login: + m.current = tui.NewChoice(m.cachedComposer != nil) return m, m.current.Init() } } - // --- Custom Messages for Switching Views and Pagination --- + case tui.BackToInboxMsg: + if m.inbox != nil { + m.current = m.inbox + } else { + m.current = tui.NewChoice(m.cachedComposer != nil) + } + return m, nil + + case tui.DiscardDraftMsg: + m.cachedComposer = msg.ComposerState + m.current = tui.NewChoice(true) // Now there is a cached draft + return m, m.current.Init() + + case tui.RestoreDraftMsg: + if m.cachedComposer != nil { + m.current = m.cachedComposer + m.cachedComposer.ResetConfirmation() + m.cachedComposer = nil // Clear cache after restoring + return m, m.current.Init() + } + case tui.Credentials: cfg := &config.Config{ ServiceProvider: msg.Provider, @@ -97,8 +115,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } m.config = cfg - m.current = tui.NewChoice() - cmds = append(cmds, m.current.Init()) + m.current = tui.NewChoice(m.cachedComposer != nil) + return m, m.current.Init() case tui.GoToInboxMsg: m.current = tui.NewStatus("Fetching emails...") @@ -108,50 +126,120 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.emails = msg.Emails m.inbox = tui.NewInbox(m.emails) m.current = m.inbox - // Manually set the size of the new view. m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.FetchMoreEmailsMsg: - cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }) - cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset)) - return m, tea.Batch(cmds...) + return m, tea.Batch( + func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }, + fetchEmails(m.config, paginationLimit, msg.Offset), + ) case tui.EmailsAppendedMsg: m.emails = append(m.emails, msg.Emails...) - // Pass the new emails to the inbox to be appended. - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + return m, nil case tui.GoToSendMsg: - m.current = tui.NewComposer(m.config.Email) + // When composing a new email, we discard any previously cached draft. + m.cachedComposer = nil + m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.GoToSettingsMsg: m.current = tui.NewLogin() m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.ViewEmailMsg: emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height) m.current = emailView - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() + + case tui.ReplyToEmailMsg: + to := msg.Email.From + subject := "Re: " + msg.Email.Subject + 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> ")) + m.current = tui.NewComposer(m.config.Email, to, subject, body) + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + + case tui.GoToFilePickerMsg: + m.previousModel = m.current + wd, _ := os.Getwd() + m.current = tui.NewFilePicker(wd) + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + + case tui.FileSelectedMsg, tui.CancelFilePickerMsg: + if m.previousModel != nil { + m.current = m.previousModel + m.previousModel = nil + } + return m, nil case tui.SendEmailMsg: + m.cachedComposer = nil // Clear cache on successful send m.current = tui.NewStatus("Sending email...") - cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg)) + return m, tea.Batch(m.current.Init(), sendEmail(m.config, msg)) case tui.EmailResultMsg: - m.current = tui.NewChoice() - cmds = append(cmds, m.current.Init()) + m.current = tui.NewChoice(m.cachedComposer != nil) + return m, m.current.Init() + + case tui.DeleteEmailMsg: + m.previousModel = m.current + m.current = tui.NewStatus("Deleting email...") + return m, tea.Batch(m.current.Init(), deleteEmailCmd(m.config, msg.UID)) + + case tui.ArchiveEmailMsg: + m.previousModel = m.current + m.current = tui.NewStatus("Archiving email...") + return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID)) + + case tui.EmailActionDoneMsg: + if msg.Err != nil { + log.Printf("Action failed: %v", msg.Err) + m.current = m.inbox + return m, nil + } + var updatedEmails []fetcher.Email + for _, email := range m.emails { + if email.UID != msg.UID { + updatedEmails = append(updatedEmails, email) + } + } + m.emails = updatedEmails + m.inbox = tui.NewInbox(m.emails) + m.current = m.inbox + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + + case tui.DownloadAttachmentMsg: + m.previousModel = m.current + m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename)) + return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(msg)) + + case tui.AttachmentDownloadedMsg: + var statusMsg string + if msg.Err != nil { + statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err) + } else { + statusMsg = fmt.Sprintf("Saved to %s", msg.Path) + } + m.current = tui.NewStatus(statusMsg) + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + + case tui.RestoreViewMsg: + if m.previousModel != nil { + m.current = m.previousModel + m.previousModel = nil + } + return m, nil } - // Pass all other messages to the current view. - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } @@ -159,40 +247,32 @@ func (m *mainModel) View() string { return m.current.View() } -// markdownToHTML converts a Markdown string to an HTML string. func markdownToHTML(md []byte) []byte { var buf bytes.Buffer - p := goldmark.New( - goldmark.WithRendererOptions( - html.WithUnsafe(), // Allow raw HTML in email. - ), - ) + p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe())) if err := p.Convert(md, &buf); err != nil { - return md // Fallback to original markdown. + return md } return buf.Bytes() } -// sendEmail finds local image paths, embeds them, and sends the email. func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { return func() tea.Msg { recipients := []string{msg.To} body := msg.Body images := make(map[string][]byte) + attachments := make(map[string][]byte) - // Find all markdown image tags. re := regexp.MustCompile(`!\[.*?\]\((.*?)\)`) matches := re.FindAllStringSubmatch(body, -1) for _, match := range matches { imgPath := match[1] - imgData, err := os.ReadFile(imgPath) // Use os.ReadFile. + imgData, err := os.ReadFile(imgPath) if err != nil { log.Printf("Could not read image file %s: %v", imgPath, err) continue } - - // Create a unique CID that includes the file extension. cid := fmt.Sprintf("%s%s@%s", uuid.NewString(), filepath.Ext(imgPath), "email-cli") images[cid] = []byte(base64.StdEncoding.EncodeToString(imgData)) body = strings.Replace(body, imgPath, "cid:"+cid, 1) @@ -200,17 +280,25 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { htmlBody := markdownToHTML([]byte(body)) - err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images) + if msg.AttachmentPath != "" { + fileData, err := os.ReadFile(msg.AttachmentPath) + if err != nil { + log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err) + } else { + _, filename := filepath.Split(msg.AttachmentPath) + attachments[filename] = fileData + } + } + + err := sender.SendEmail(cfg, recipients, msg.Subject, msg.Body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} } - time.Sleep(1 * time.Second) return tui.EmailResultMsg{} } } -// fetchEmails retrieves emails in the background and dispatches the correct message. func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd { return func() tea.Msg { emails, err := fetcher.FetchEmails(cfg, limit, offset) @@ -218,19 +306,48 @@ func fetchEmails(cfg *config.Config, limit, offset uint32) tea.Cmd { return tui.FetchErr(err) } if offset == 0 { - // This is the initial fetch. return tui.EmailsFetchedMsg{Emails: emails} } - // This is a subsequent fetch for pagination. return tui.EmailsAppendedMsg{Emails: emails} } } +func deleteEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { + return func() tea.Msg { + err := fetcher.DeleteEmail(cfg, uid) + return tui.EmailActionDoneMsg{UID: uid, Err: err} + } +} + +func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { + return func() tea.Msg { + err := fetcher.ArchiveEmail(cfg, uid) + return tui.EmailActionDoneMsg{UID: uid, Err: err} + } +} + +func downloadAttachmentCmd(msg tui.DownloadAttachmentMsg) tea.Cmd { + return func() tea.Msg { + homeDir, err := os.UserHomeDir() + if err != nil { + return tui.AttachmentDownloadedMsg{Err: err} + } + downloadsPath := filepath.Join(homeDir, "Downloads") + if _, err := os.Stat(downloadsPath); os.IsNotExist(err) { + if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil { + return tui.AttachmentDownloadedMsg{Err: mkErr} + } + } + filePath := filepath.Join(downloadsPath, msg.Filename) + err = os.WriteFile(filePath, msg.Data, 0644) + return tui.AttachmentDownloadedMsg{Path: filePath, Err: err} + } +} + func main() { cfg, err := config.LoadConfig() var initialModel *mainModel if err != nil { - // If config doesn't exist, guide the user to create one via the login view. initialModel = newInitialModel(nil) } else { initialModel = newInitialModel(cfg) @@ -242,4 +359,4 @@ func main() { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/sender/sender.go b/sender/sender.go index 7cf4537c547f763989bb87e456a90bf591dbfc2e..d45df8d66f48fe8271ed6d001425dcf4d6d96a4c 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -26,8 +26,8 @@ func generateMessageID(from string) string { return fmt.Sprintf("<%x@%s>", buf, from) } -// SendEmail constructs a multipart message with plain text, HTML, and embedded images. -func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte) error { +// SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. +func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string) error { var smtpServer string var smtpPort int @@ -53,55 +53,72 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str var msg bytes.Buffer mainWriter := multipart.NewWriter(&msg) - // Set top-level headers for multipart/related + // Set top-level headers for a mixed message type to support content and attachments headers := map[string]string{ "From": fromHeader, "To": to[0], "Subject": subject, "Date": time.Now().Format(time.RFC1123Z), "Message-ID": generateMessageID(cfg.Email), - "Content-Type": "multipart/related; boundary=" + mainWriter.Boundary(), + "Content-Type": "multipart/mixed; boundary=" + mainWriter.Boundary(), } + + if inReplyTo != "" { + headers["In-Reply-To"] = inReplyTo + if len(references) > 0 { + headers["References"] = strings.Join(references, " ") + " " + inReplyTo + } else { + headers["References"] = inReplyTo + } + } + for k, v := range headers { fmt.Fprintf(&msg, "%s: %s\r\n", k, v) } fmt.Fprintf(&msg, "\r\n") // End of headers - // Create the multipart/alternative part as a nested part + // --- Body Part (multipart/related) --- + // This part contains the multipart/alternative (text/html) and any inline images. + relatedHeader := textproto.MIMEHeader{} + relatedBoundary := "related-" + mainWriter.Boundary() + relatedHeader.Set("Content-Type", "multipart/related; boundary="+relatedBoundary) + relatedPartWriter, err := mainWriter.CreatePart(relatedHeader) + if err != nil { + return err + } + relatedWriter := multipart.NewWriter(relatedPartWriter) + relatedWriter.SetBoundary(relatedBoundary) + + // --- Alternative Part (text and html) --- altHeader := textproto.MIMEHeader{} altBoundary := "alt-" + mainWriter.Boundary() altHeader.Set("Content-Type", "multipart/alternative; boundary="+altBoundary) - altPartWriter, err := mainWriter.CreatePart(altHeader) + altPartWriter, err := relatedWriter.CreatePart(altHeader) if err != nil { return err } - altWriter := multipart.NewWriter(altPartWriter) altWriter.SetBoundary(altBoundary) - // Create plain text part inside multipart/alternative - textHeader := textproto.MIMEHeader{} - textHeader.Set("Content-Type", "text/plain; charset=UTF-8") - textPart, err := altWriter.CreatePart(textHeader) + // Plain text part + textPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}}) if err != nil { return err } fmt.Fprint(textPart, plainBody) - // Create HTML part inside multipart/alternative - htmlHeader := textproto.MIMEHeader{} - htmlHeader.Set("Content-Type", "text/html; charset=UTF-8") - htmlPart, err := altWriter.CreatePart(htmlHeader) + // HTML part + htmlPart, err := altWriter.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}}) if err != nil { return err } fmt.Fprint(htmlPart, htmlBody) - altWriter.Close() + altWriter.Close() // Finish the alternative part - // Attach images to the main multipart/related part + // --- Inline Images --- for cid, data := range images { - ext := filepath.Ext(strings.Split(cid, "@")[0]) // Extract extension from CID + ext := filepath.Ext(strings.Split(cid, "@")[0]) mimeType := mime.TypeByExtension(ext) if mimeType == "" { mimeType = "application/octet-stream" @@ -113,18 +130,36 @@ func SendEmail(cfg *config.Config, to []string, subject, plainBody, htmlBody str imgHeader.Set("Content-ID", "<"+cid+">") imgHeader.Set("Content-Disposition", "inline; filename=\""+cid+"\"") - imgPart, err := mainWriter.CreatePart(imgHeader) + imgPart, err := relatedWriter.CreatePart(imgHeader) if err != nil { return err } - decodedData, err := base64.StdEncoding.DecodeString(string(data)) + imgPart.Write(data) // data is already base64 encoded + } + + relatedWriter.Close() // Finish the related part + + // --- Attachments --- + for filename, data := range attachments { + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Type", mimeType) + partHeader.Set("Content-Transfer-Encoding", "base64") + partHeader.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + attachmentPart, err := mainWriter.CreatePart(partHeader) if err != nil { return err } - imgPart.Write(decodedData) + encodedData := base64.StdEncoding.EncodeToString(data) + attachmentPart.Write([]byte(encodedData)) } - mainWriter.Close() + mainWriter.Close() // Finish the main message addr := fmt.Sprintf("%s:%d", smtpServer, smtpPort) return smtp.SendMail(addr, auth, cfg.Email, to, msg.Bytes()) diff --git a/tui/choice.go b/tui/choice.go index 83bb378962ef19b06137dd0b6c9f4478963b2f3e..58fc537f8b5caad3034d5d1ff8cd22b599c0094c 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -8,34 +8,30 @@ import ( "github.com/charmbracelet/lipgloss" ) +// Styles defined locally to avoid import issues. var ( - // A more beautiful style for the main menu - docStyle = lipgloss.NewStyle().Margin(1, 2) - - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFDF5")). - Background(lipgloss.Color("#25A065")). - Padding(0, 1) - - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - // Styling for the choice list - listHeader = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - PaddingBottom(1) - - // Custom item styles + docStyle = lipgloss.NewStyle().Margin(1, 2) + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFDF5")).Background(lipgloss.Color("#25A065")).Padding(0, 1) + listHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).PaddingBottom(1) itemStyle = lipgloss.NewStyle().PaddingLeft(2) selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205")) ) type Choice struct { - cursor int + cursor int + choices []string + hasCachedDraft bool } -func NewChoice() Choice { - return Choice{cursor: 0} +func NewChoice(hasCachedDraft bool) Choice { + choices := []string{"View Inbox", "Compose Email", "Settings"} + if hasCachedDraft { + choices = append(choices, "Restore Draft") + } + return Choice{ + choices: choices, + hasCachedDraft: hasCachedDraft, + } } func (m Choice) Init() tea.Cmd { @@ -51,17 +47,20 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor-- } case "down", "j": - if m.cursor < 2 { // We have three choices now + if m.cursor < len(m.choices)-1 { m.cursor++ } case "enter": - switch m.cursor { - case 0: + selectedChoice := m.choices[m.cursor] + switch selectedChoice { + case "View Inbox": return m, func() tea.Msg { return GoToInboxMsg{} } - case 1: + case "Compose Email": return m, func() tea.Msg { return GoToSendMsg{} } - case 2: + case "Settings": return m, func() tea.Msg { return GoToSettingsMsg{} } + case "Restore Draft": + return m, func() tea.Msg { return RestoreDraftMsg{} } } } } @@ -71,16 +70,11 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Choice) View() string { var b strings.Builder - // Title b.WriteString(titleStyle.Render("Email CLI") + "\n\n") - - // Header b.WriteString(listHeader.Render("What would you like to do?")) b.WriteString("\n\n") - // Choices - choices := []string{"View Inbox", "Compose Email", "Settings"} - for i, choice := range choices { + for i, choice := range m.choices { if m.cursor == i { b.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", choice))) } else { @@ -89,7 +83,6 @@ func (m Choice) View() string { b.WriteString("\n") } - // Help b.WriteString("\n\n") b.WriteString(helpStyle.Render("Use ↑/↓ to navigate, enter to select, and q to quit.")) diff --git a/tui/composer.go b/tui/composer.go index 0a291c0622ee4820538865dcf18aa3783103f32f..0f8b14993164ebeb9b0701424b51135010128a21 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -1,6 +1,9 @@ package tui import ( + "fmt" + "strings" + "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -13,27 +16,34 @@ var ( blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) cursorStyle = focusedStyle.Copy() noStyle = lipgloss.NewStyle() + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) focusedButton = focusedStyle.Copy().Render("[ Send ]") blurredButton = blurredStyle.Copy().Render("[ Send ]") emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) + attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style ) // Composer model holds the state of the email composition UI. type Composer struct { - focusIndex int - toInput textinput.Model - subjectInput textinput.Model - bodyInput textarea.Model - fromAddr string + focusIndex int + toInput textinput.Model + subjectInput textinput.Model + bodyInput textarea.Model + attachmentPath string + fromAddr string + width int + height int + confirmingExit bool } // NewComposer initializes a new composer model. -func NewComposer(from string) *Composer { +func NewComposer(from, to, subject, body string) *Composer { m := &Composer{fromAddr: from} m.toInput = textinput.New() m.toInput.Cursor.Style = cursorStyle m.toInput.Placeholder = "To" + m.toInput.SetValue(to) m.toInput.Focus() m.toInput.Prompt = "> " m.toInput.CharLimit = 256 @@ -41,18 +51,26 @@ func NewComposer(from string) *Composer { m.subjectInput = textinput.New() m.subjectInput.Cursor.Style = cursorStyle m.subjectInput.Placeholder = "Subject" + m.subjectInput.SetValue(subject) m.subjectInput.Prompt = "> " m.subjectInput.CharLimit = 256 m.bodyInput = textarea.New() m.bodyInput.Cursor.Style = cursorStyle m.bodyInput.Placeholder = "Body (Markdown supported)..." + m.bodyInput.SetValue(body) m.bodyInput.Prompt = "> " m.bodyInput.SetHeight(10) + m.bodyInput.SetCursor(0) return m } +// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt. +func (m *Composer) ResetConfirmation() { + m.confirmingExit = false +} + func (m *Composer) Init() tea.Cmd { return textinput.Blink } @@ -63,19 +81,41 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - // When the window is resized, update the width of the inputs. - inputWidth := msg.Width - 6 // Subtract for padding and prompt + m.width = msg.Width + m.height = msg.Height + inputWidth := msg.Width - 6 m.toInput.Width = inputWidth m.subjectInput.Width = inputWidth m.bodyInput.SetWidth(inputWidth) + case SetComposerCursorToStartMsg: + m.bodyInput.SetCursor(0) + return m, nil + + case FileSelectedMsg: + m.attachmentPath = msg.Path + return m, nil + case tea.KeyMsg: + if m.confirmingExit { + switch msg.String() { + case "y", "Y": + return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} } + case "n", "N", "esc": + m.confirmingExit = false + return m, nil + default: + return m, nil + } + } + switch msg.Type { - // IMPORTANT: Removed tea.KeyEsc from this case case tea.KeyCtrlC: return m, tea.Quit + case tea.KeyEsc: + m.confirmingExit = true + return m, nil - // Handle Tab and Shift+Tab to cycle focus between inputs. case tea.KeyTab, tea.KeyShiftTab: if msg.Type == tea.KeyShiftTab { m.focusIndex-- @@ -83,19 +123,16 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusIndex++ } - // Wrap around - if m.focusIndex > 3 { // 3 is the Send button + if m.focusIndex > 4 { m.focusIndex = 0 } else if m.focusIndex < 0 { - m.focusIndex = 3 + m.focusIndex = 4 } - // Blur all inputs m.toInput.Blur() m.subjectInput.Blur() m.bodyInput.Blur() - // Focus the correct input switch m.focusIndex { case 0: cmds = append(cmds, m.toInput.Focus()) @@ -103,25 +140,27 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, m.subjectInput.Focus()) case 2: cmds = append(cmds, m.bodyInput.Focus()) + cmds = append(cmds, func() tea.Msg { return SetComposerCursorToStartMsg{} }) } return m, tea.Batch(cmds...) - // Handle Enter key. case tea.KeyEnter: - // If on the Send button, send the email. if m.focusIndex == 3 { + return m, func() tea.Msg { return GoToFilePickerMsg{} } + } + if m.focusIndex == 4 { return m, func() tea.Msg { return SendEmailMsg{ - To: m.toInput.Value(), - Subject: m.subjectInput.Value(), - Body: m.bodyInput.Value(), + To: m.toInput.Value(), + Subject: m.subjectInput.Value(), + Body: m.bodyInput.Value(), + AttachmentPath: m.attachmentPath, } } } } } - // Update the focused input. switch m.focusIndex { case 0: m.toInput, cmd = m.toInput.Update(msg) @@ -137,20 +176,48 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// View renders the UI. func (m *Composer) View() string { - button := &blurredButton + var composerView strings.Builder + var button string + + if m.focusIndex == 4 { + button = focusedButton + } else { + button = blurredButton + } + + var attachmentField string + attachmentText := "None (Press Enter to select)" + if m.attachmentPath != "" { + attachmentText = m.attachmentPath + } + if m.focusIndex == 3 { - button = &focusedButton + attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText)) + } else { + attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachment: %s", attachmentText)) } - return lipgloss.JoinVertical(lipgloss.Left, + composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left, "Compose New Email", "From: "+emailRecipientStyle.Render(m.fromAddr), m.toInput.View(), m.subjectInput.View(), m.bodyInput.View(), - *button, - helpStyle.Render("Markdown enabled! • tab: next field • esc: back to menu"), - ) + attachmentStyle.Render(attachmentField), + button, + helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"), + )) + + if m.confirmingExit { + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + "Discard draft?", + HelpStyle.Render("\n(y/n)"), + ), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) + } + + return composerView.String() } \ No newline at end of file diff --git a/tui/composer_test.go b/tui/composer_test.go index 007051959f0739be395e91cb6644e8155683fb5f..922dcaa5154eacfdab7e1475910598bf3f15f10b 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -10,7 +10,7 @@ import ( // TestComposerUpdate verifies the state transitions in the email composer. func TestComposerUpdate(t *testing.T) { // Initialize a new composer. - composer := NewComposer("test@example.com") + composer := NewComposer("test@example.com", "", "", "") t.Run("Focus cycling", func(t *testing.T) { // Initial focus is on the 'To' input (index 0). @@ -19,31 +19,38 @@ func TestComposerUpdate(t *testing.T) { } // Simulate pressing Tab to move to the 'Subject' field. - model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model - composer = model.(*Composer) // Cast to *Composer + model, _ := composer.Update(tea.KeyMsg{Type: tea.KeyTab}) + composer = model.(*Composer) if composer.focusIndex != 1 { - t.Errorf("After one Tab, focusIndex should be 1, got %d", composer.focusIndex) + t.Errorf("After one Tab, focusIndex should be 1 (Subject), got %d", composer.focusIndex) } // Simulate pressing Tab again to move to the 'Body' field. - model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model - composer = model.(*Composer) // Cast to *Composer + model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) + composer = model.(*Composer) if composer.focusIndex != 2 { - t.Errorf("After two Tabs, focusIndex should be 2, got %d", composer.focusIndex) + t.Errorf("After two Tabs, focusIndex should be 2 (Body), got %d", composer.focusIndex) } - // Simulate pressing Tab again to move to the 'Send' button. - model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model - composer = model.(*Composer) // Cast to *Composer + // Simulate pressing Tab again to move to the 'Attachment' field. + model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) + composer = model.(*Composer) if composer.focusIndex != 3 { - t.Errorf("After three Tabs, focusIndex should be 3 (Send), got %d", composer.focusIndex) + t.Errorf("After three Tabs, focusIndex should be 3 (Attachment), got %d", composer.focusIndex) + } + + // Simulate pressing Tab again to move to the 'Send' button. + model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) + composer = model.(*Composer) + if composer.focusIndex != 4 { + t.Errorf("After four Tabs, focusIndex should be 4 (Send), got %d", composer.focusIndex) } // Simulate one more Tab to wrap around to the 'To' field. - model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) // model is tea.Model - composer = model.(*Composer) // Cast to *Composer + model, _ = composer.Update(tea.KeyMsg{Type: tea.KeyTab}) + composer = model.(*Composer) if composer.focusIndex != 0 { - t.Errorf("After four Tabs, focusIndex should wrap to 0, got %d", composer.focusIndex) + t.Errorf("After five Tabs, focusIndex should wrap to 0, got %d", composer.focusIndex) } }) @@ -53,7 +60,7 @@ func TestComposerUpdate(t *testing.T) { composer.subjectInput.SetValue("Test Subject") composer.bodyInput.SetValue("This is the body.") // Set focus to the Send button. - composer.focusIndex = 3 + composer.focusIndex = 4 // Simulate pressing Enter to send the email. _, cmd := composer.Update(tea.KeyMsg{Type: tea.KeyEnter}) @@ -70,12 +77,13 @@ func TestComposerUpdate(t *testing.T) { // Verify the content of the message. expectedMsg := SendEmailMsg{ - To: "recipient@example.com", - Subject: "Test Subject", - Body: "This is the body.", + To: "recipient@example.com", + Subject: "Test Subject", + Body: "This is the body.", + AttachmentPath: "", // Expect empty attachment path in this test } if !reflect.DeepEqual(sendMsg, expectedMsg) { t.Errorf("Mismatched SendEmailMsg.\nGot: %+v\nWant: %+v", sendMsg, expectedMsg) } }) -} +} \ No newline at end of file diff --git a/tui/email_view.go b/tui/email_view.go index ac1778a7674523b4fe722f1c4a1593f5b898f13c..97834e25bcbc0967350a8ae6c677135edfd83ec4 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "github.com/andrinoff/email-cli/fetcher" "github.com/andrinoff/email-cli/view" @@ -11,15 +12,15 @@ import ( ) var ( - emailHeaderStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - Padding(0, 1) + emailHeaderStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1) + attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1) ) type EmailView struct { - viewport viewport.Model - email fetcher.Email + viewport viewport.Model + email fetcher.Email + attachmentCursor int + focusOnAttachments bool } func NewEmailView(email fetcher.Email, width, height int) *EmailView { @@ -29,9 +30,15 @@ func NewEmailView(email fetcher.Email, width, height int) *EmailView { } header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) - headerHeight := lipgloss.Height(header) + 2 // Account for padding and border + headerHeight := lipgloss.Height(header) + 2 - vp := viewport.New(width, height-headerHeight) + // Calculate height for attachments if they exist + attachmentHeight := 0 + if len(email.Attachments) > 0 { + attachmentHeight = len(email.Attachments) + 2 // +2 for title and border + } + + vp := viewport.New(width, height-headerHeight-attachmentHeight) vp.SetContent(body) return &EmailView{ @@ -46,19 +53,93 @@ func (m *EmailView) Init() tea.Cmd { func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle 'esc' key locally + if msg.Type == tea.KeyEsc { + if m.focusOnAttachments { + m.focusOnAttachments = false + return m, nil + } + return m, func() tea.Msg { return BackToInboxMsg{} } + } + + if m.focusOnAttachments { + switch msg.String() { + case "up", "k": + if m.attachmentCursor > 0 { + m.attachmentCursor-- + } + case "down", "j": + if m.attachmentCursor < len(m.email.Attachments)-1 { + m.attachmentCursor++ + } + case "enter": + if len(m.email.Attachments) > 0 { + selected := m.email.Attachments[m.attachmentCursor] + return m, func() tea.Msg { + return DownloadAttachmentMsg{Filename: selected.Filename, Data: selected.Data} + } + } + case "tab": + m.focusOnAttachments = false + } + } else { + switch msg.String() { + case "r": + return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} } + case "tab": + if len(m.email.Attachments) > 0 { + m.focusOnAttachments = true + } + } + } case tea.WindowSizeMsg: header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject) headerHeight := lipgloss.Height(header) + 2 + attachmentHeight := 0 + if len(m.email.Attachments) > 0 { + attachmentHeight = len(m.email.Attachments) + 2 + } m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - headerHeight + m.viewport.Height = msg.Height - headerHeight - attachmentHeight } + m.viewport, cmd = m.viewport.Update(msg) - return m, cmd + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) } func (m *EmailView) View() string { header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject) styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header) - return fmt.Sprintf("%s\n%s", styledHeader, m.viewport.View()) + + var help string + if m.focusOnAttachments { + help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body") + } else { + help = helpStyle.Render("r: reply • tab: focus attachments • esc: back to inbox") + } + + var attachmentView string + if len(m.email.Attachments) > 0 { + var b strings.Builder + b.WriteString("Attachments:\n") + for i, attachment := range m.email.Attachments { + cursor := " " + style := itemStyle + if m.focusOnAttachments && i == m.attachmentCursor { + cursor = "> " + style = selectedItemStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename))) + b.WriteString("\n") + } + attachmentView = attachmentBoxStyle.Render(b.String()) + } + + return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help) } \ No newline at end of file diff --git a/tui/filepicker.go b/tui/filepicker.go new file mode 100644 index 0000000000000000000000000000000000000000..a950337443ee4b8f481e168e2f54f38abaf00e81 --- /dev/null +++ b/tui/filepicker.go @@ -0,0 +1,125 @@ +package tui + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + filePickerItemStyle = lipgloss.NewStyle().PaddingLeft(2) + filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205")) + directoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99")) +) + +type FilePicker struct { + cursor int + currentPath string + items []fs.DirEntry + width int + height int +} + +func NewFilePicker(startPath string) *FilePicker { + fp := &FilePicker{currentPath: startPath} + fp.readDir() + return fp +} + +func (m *FilePicker) readDir() { + files, err := os.ReadDir(m.currentPath) + if err != nil { + // Handle error, maybe show a message + m.items = []fs.DirEntry{} + return + } + m.items = files + m.cursor = 0 // Reset cursor +} + +func (m *FilePicker) Init() tea.Cmd { + return nil +} + +func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + case "enter": + if len(m.items) == 0 { + return m, nil + } + selectedItem := m.items[m.cursor] + newPath := filepath.Join(m.currentPath, selectedItem.Name()) + + if selectedItem.IsDir() { + m.currentPath = newPath + m.readDir() + } else { + // It's a file, send a message with the path + return m, func() tea.Msg { + return FileSelectedMsg{Path: newPath} + } + } + case "backspace": + // Go up one directory + parentDir := filepath.Dir(m.currentPath) + if parentDir != m.currentPath { // Avoid getting stuck at root + m.currentPath = parentDir + m.readDir() + } + case "esc", "q": + return m, func() tea.Msg { return CancelFilePickerMsg{} } + } + } + return m, nil +} + +func (m *FilePicker) View() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Select a File") + "\n") + b.WriteString(fmt.Sprintf("Current Path: %s\n\n", m.currentPath)) + + for i, item := range m.items { + cursor := " " + if m.cursor == i { + cursor = "> " + } + + itemName := item.Name() + if item.IsDir() { + itemName = directoryStyle.Render(itemName + "/") + } + + line := fmt.Sprintf("%s%s", cursor, itemName) + + if m.cursor == i { + b.WriteString(filePickerSelectedItemStyle.Render(line)) + } else { + b.WriteString(filePickerItemStyle.Render(line)) + } + b.WriteString("\n") + } + + b.WriteString("\n" + helpStyle.Render("↑/↓: navigate • enter: select • backspace: up • esc: cancel")) + + return docStyle.Render(b.String()) +} \ No newline at end of file diff --git a/tui/inbox.go b/tui/inbox.go index f470a6eb818c00d8ea85c6fc2fe603312419ea2e..ee67c31b85872aafae7feda185239e9bd1220f13 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -5,6 +5,7 @@ import ( "io" "github.com/andrinoff/email-cli/fetcher" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -15,10 +16,10 @@ var ( inboxHelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) ) -// item now holds its original index from the main email slice. type item struct { title, desc string originalIndex int + uid uint32 // Added UID to item } func (i item) Title() string { return i.title } @@ -48,7 +49,6 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list fmt.Fprint(w, fn(str)) } -// Inbox is now stateful to handle pagination. type Inbox struct { list list.Model isFetching bool @@ -61,7 +61,8 @@ func NewInbox(emails []fetcher.Email) *Inbox { items[i] = item{ title: email.Subject, desc: email.From, - originalIndex: i, // Store the original index here. + originalIndex: i, + uid: email.UID, // Store UID } } @@ -73,6 +74,12 @@ func NewInbox(emails []fetcher.Email) *Inbox { l.Styles.PaginationStyle = paginationStyle l.Styles.HelpStyle = inboxHelpStyle l.SetStatusBarItemName("email", "emails") + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")), + key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "archive")), + } + } return &Inbox{ list: l, @@ -90,13 +97,28 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - // When the user presses enter, we look at the selected item and send - // a message with its *original* index. - if msg.String() == "enter" { + if m.list.FilterState() == list.Filtering { + break + } + switch keypress := msg.String(); keypress { + case "d": + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + return m, func() tea.Msg { + return DeleteEmailMsg{UID: selectedItem.uid} + } + } + case "a": + selectedItem, ok := m.list.SelectedItem().(item) + if ok { + return m, func() tea.Msg { + return ArchiveEmailMsg{UID: selectedItem.uid} + } + } + case "enter": selectedItem, ok := m.list.SelectedItem().(item) if ok { return m, func() tea.Msg { - // Use the stored original index, which is correct even when filtered. return ViewEmailMsg{Index: selectedItem.originalIndex} } } @@ -118,7 +140,8 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newItems[i] = item{ title: email.Subject, desc: email.From, - originalIndex: m.emailsCount + i, // The original index continues to grow. + originalIndex: m.emailsCount + i, + uid: email.UID, } } currentItems := m.list.Items() @@ -129,26 +152,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } - // Infinite scroll logic if !m.isFetching && len(m.list.Items()) > 0 && m.list.Index() >= len(m.list.Items())-5 { cmds = append(cmds, func() tea.Msg { return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)} }) } - // New logic to fetch more emails when filtering is active - if m.list.FilterState() == list.Filtering && !m.isFetching { - m.isFetching = true - m.list.Title = "Fetching more emails..." - cmds = append(cmds, func() tea.Msg { - return FetchMoreEmailsMsg{Offset: uint32(m.emailsCount)} - }) - } - var cmd tea.Cmd - var newModel list.Model - newModel, cmd = m.list.Update(msg) - m.list = newModel + m.list, cmd = m.list.Update(msg) cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } diff --git a/tui/messages.go b/tui/messages.go index 36da0b9e365d1715f331f57b1bfe097cc02104cb..8da7fe7fe192e56d6e5b41ff3d4f5070a001332f 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -2,19 +2,19 @@ package tui import "github.com/andrinoff/email-cli/fetcher" -// A message to view an email. type ViewEmailMsg struct { Index int } -// A message to indicate that an email has been sent. type SendEmailMsg struct { - To string - Subject string - Body string + To string + Subject string + Body string + AttachmentPath string + InReplyTo string + References []string } -// A message to indicate that the user has entered their credentials. type Credentials struct { Provider string Name string @@ -22,46 +22,91 @@ type Credentials struct { Password string } -// A message to indicate that the user has chosen a service. type ChooseServiceMsg struct { Service string } -// EmailResultMsg is sent after an email sending attempt. -// If Err is not nil, the email failed to send. type EmailResultMsg struct { Err error } -// ClearStatusMsg is sent to clear the status message from the view. type ClearStatusMsg struct{} -// A message containing the fetched emails. type EmailsFetchedMsg struct { Emails []fetcher.Email } -// A message to indicate that an error occurred while fetching emails. type FetchErr error -// A message to navigate to the inbox view. type GoToInboxMsg struct{} -// A message to navigate to the composer view. -type GoToSendMsg struct{} +type GoToSendMsg struct { + To string + Subject string + Body string +} -// A message to navigate to the settings view. type GoToSettingsMsg struct{} -// A message to fetch more emails with a given offset. type FetchMoreEmailsMsg struct { Offset uint32 } -// A message to indicate that the app is fetching more emails. type FetchingMoreEmailsMsg struct{} -// A message to indicate that new emails have been fetched and should be appended. type EmailsAppendedMsg struct { Emails []fetcher.Email -} \ No newline at end of file +} + +type ReplyToEmailMsg struct { + Email fetcher.Email +} + +type SetComposerCursorToStartMsg struct{} + +type GoToFilePickerMsg struct{} + +type FileSelectedMsg struct { + Path string +} + +type CancelFilePickerMsg struct{} + +type DeleteEmailMsg struct { + UID uint32 +} + +type ArchiveEmailMsg struct { + UID uint32 +} + +type EmailActionDoneMsg struct { + UID uint32 + Err error +} + +type GoToChoiceMenuMsg struct{} + +type DownloadAttachmentMsg struct { + Filename string + Data []byte +} + +type AttachmentDownloadedMsg struct { + Path string + Err error +} + +type RestoreViewMsg struct{} + +type BackToInboxMsg struct{} + +// --- Draft Messages --- + +// DiscardDraftMsg signals that a draft should be cached. +type DiscardDraftMsg struct { + ComposerState *Composer +} + +// RestoreDraftMsg signals that the cached draft should be restored. +type RestoreDraftMsg struct{} \ No newline at end of file