Detailed changes
@@ -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)
}
@@ -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)
}
-}
+}
@@ -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())
@@ -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."))
@@ -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()
}
@@ -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)
}
})
-}
+}
@@ -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)
}
@@ -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())
+}
@@ -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...)
}
@@ -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
-}
+}
+
+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{}