Detailed changes
@@ -1,6 +1,7 @@
package fetcher
import (
+ "encoding/base64"
"fmt"
"io"
"io/ioutil"
@@ -17,15 +18,22 @@ import (
"golang.org/x/text/transform"
)
+// Attachment holds data for an email attachment.
+type Attachment struct {
+ Filename string
+ Data []byte
+}
+
type Email struct {
- UID uint32 // Added UID to uniquely identify emails
- From string
- To []string
- Subject string
- Body string
- Date time.Time
- MessageID string
- References []string
+ UID uint32
+ From string
+ To []string
+ Subject string
+ Body string
+ Date time.Time
+ MessageID string
+ References []string
+ Attachments []Attachment
}
func decodePart(reader io.Reader, header mail.PartHeader) (string, error) {
@@ -74,7 +82,6 @@ func decodeHeader(header string) string {
return decoded
}
-// connect initializes a connection to the IMAP server.
func connect(cfg *config.Config) (*client.Client, error) {
var imapServer string
var imapPort int
@@ -176,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 {
@@ -185,25 +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{
- UID: msg.Uid,
- From: fromAddr,
- To: toAddrList,
- Subject: subject,
- Body: body,
- Date: date,
- MessageID: messageID,
- References: strings.Fields(references),
+ UID: msg.Uid,
+ From: fromAddr,
+ To: toAddrList,
+ Subject: subject,
+ Body: body,
+ Date: date,
+ MessageID: messageID,
+ References: strings.Fields(references),
+ Attachments: attachments,
})
}
@@ -218,7 +248,6 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) {
return emails, nil
}
-// moveEmail moves an email to a specified destination mailbox.
func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
c, err := connect(cfg)
if err != nil {
@@ -236,7 +265,6 @@ func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error {
return c.UidMove(seqSet, destMailbox)
}
-// DeleteEmail moves an email to the trash folder.
func DeleteEmail(cfg *config.Config, uid uint32) error {
var trashMailbox string
switch cfg.ServiceProvider {
@@ -248,7 +276,6 @@ func DeleteEmail(cfg *config.Config, uid uint32) error {
return moveEmail(cfg, uid, trashMailbox)
}
-// ArchiveEmail moves an email to the archive folder.
func ArchiveEmail(cfg *config.Config, uid uint32) error {
var archiveMailbox string
switch cfg.ServiceProvider {
@@ -27,26 +27,28 @@ const (
)
type mainModel struct {
- current tea.Model
- previousModel 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
}
func newInitialModel(cfg *config.Config) *mainModel {
+ // 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 {
@@ -57,13 +59,14 @@ 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
- 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" {
@@ -71,17 +74,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if msg.String() == "esc" {
switch m.current.(type) {
- case *tui.EmailView:
- m.current = m.inbox
- return m, nil
case *tui.FilePicker:
return m, func() tea.Msg { return tui.CancelFilePickerMsg{} }
- case *tui.Inbox, *tui.Composer, *tui.Login:
- m.current = tui.NewChoice()
+ case *tui.Inbox, *tui.Login:
+ m.current = tui.NewChoice(m.cachedComposer != nil)
return m, m.current.Init()
}
}
+ 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,
@@ -94,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...")
@@ -106,33 +127,34 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.inbox = tui.NewInbox(m.emails)
m.current = m.inbox
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...)
- m.current, cmd = m.current.Update(msg)
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
+ return m, nil
case tui.GoToSendMsg:
+ // 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
@@ -140,7 +162,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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})
- cmds = append(cmds, m.current.Init())
+ return m, m.current.Init()
case tui.GoToFilePickerMsg:
m.previousModel = m.current
@@ -149,16 +171,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
return m, m.current.Init()
- case tui.FileSelectedMsg:
- if m.previousModel != nil {
- m.previousModel, cmd = m.previousModel.Update(msg)
- cmds = append(cmds, cmd)
- m.current = m.previousModel
- m.previousModel = nil
- }
- return m, tea.Batch(cmds...)
-
- case tui.CancelFilePickerMsg:
+ case tui.FileSelectedMsg, tui.CancelFilePickerMsg:
if m.previousModel != nil {
m.current = m.previousModel
m.previousModel = nil
@@ -166,30 +179,30 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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...")
- cmds = append(cmds, m.current.Init(), deleteEmailCmd(m.config, msg.UID))
+ 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...")
- cmds = append(cmds, m.current.Init(), archiveEmailCmd(m.config, msg.UID))
+ return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID))
case tui.EmailActionDoneMsg:
if msg.Err != nil {
- // In a real app, you might show an error message.
- // For now, we'll just go back to the inbox.
log.Printf("Action failed: %v", msg.Err)
m.current = m.inbox
return m, nil
}
- // Remove the email from the local cache
var updatedEmails []fetcher.Email
for _, email := range m.emails {
if email.UID != msg.UID {
@@ -197,15 +210,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
m.emails = updatedEmails
- // Refresh the inbox view
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()
- }
- m.current, cmd = m.current.Update(msg)
- cmds = append(cmds, cmd)
+ 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
+ }
return m, tea.Batch(cmds...)
}
@@ -216,11 +249,7 @@ func (m *mainModel) View() string {
func markdownToHTML(md []byte) []byte {
var buf bytes.Buffer
- p := goldmark.New(
- goldmark.WithRendererOptions(
- html.WithUnsafe(),
- ),
- )
+ p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe()))
if err := p.Convert(md, &buf); err != nil {
return md
}
@@ -266,7 +295,6 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd {
log.Printf("Failed to send email: %v", err)
return tui.EmailResultMsg{Err: err}
}
- time.Sleep(1 * time.Second)
return tui.EmailResultMsg{}
}
}
@@ -298,6 +326,24 @@ func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd {
}
}
+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
@@ -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."))
@@ -2,6 +2,7 @@ package tui
import (
"fmt"
+ "strings"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
@@ -15,10 +16,11 @@ 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"))
+ attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style
)
// Composer model holds the state of the email composition UI.
@@ -29,6 +31,9 @@ type Composer struct {
bodyInput textarea.Model
attachmentPath string
fromAddr string
+ width int
+ height int
+ confirmingExit bool
}
// NewComposer initializes a new composer model.
@@ -61,6 +66,11 @@ func NewComposer(from, to, subject, body string) *Composer {
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
}
@@ -71,6 +81,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
inputWidth := msg.Width - 6
m.toInput.Width = inputWidth
m.subjectInput.Width = inputWidth
@@ -85,9 +97,24 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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 {
case tea.KeyCtrlC:
return m, tea.Quit
+ case tea.KeyEsc:
+ m.confirmingExit = true
+ return m, nil
case tea.KeyTab, tea.KeyShiftTab:
if msg.Type == tea.KeyShiftTab {
@@ -96,7 +123,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.focusIndex++
}
- if m.focusIndex > 4 { // 4 is the Send button
+ if m.focusIndex > 4 {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = 4
@@ -118,10 +145,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
case tea.KeyEnter:
- if m.focusIndex == 3 { // Attachment field focused
+ if m.focusIndex == 3 {
return m, func() tea.Msg { return GoToFilePickerMsg{} }
}
- if m.focusIndex == 4 { // Send button focused
+ if m.focusIndex == 4 {
return m, func() tea.Msg {
return SendEmailMsg{
To: m.toInput.Value(),
@@ -150,8 +177,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m *Composer) View() string {
+ var composerView strings.Builder
var button string
- if m.focusIndex == 4 { // Send button
+
+ if m.focusIndex == 4 {
button = focusedButton
} else {
button = blurredButton
@@ -163,13 +192,13 @@ func (m *Composer) View() string {
attachmentText = m.attachmentPath
}
- if m.focusIndex == 3 { // Attachment field
+ if m.focusIndex == 3 {
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(),
@@ -178,5 +207,17 @@ func (m *Composer) View() string {
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()
}
@@ -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,27 +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:
- switch msg.String() {
- case "r":
- return m, func() tea.Msg {
- return ReplyToEmailMsg{Email: m.email}
+ // 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)
- help := helpStyle.Render("r: reply • esc: back to inbox")
- return fmt.Sprintf("%s\n%s\n%s", styledHeader, m.viewport.View(), help)
+
+ 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)
}
@@ -72,8 +72,6 @@ type FileSelectedMsg struct {
type CancelFilePickerMsg struct{}
-// --- Email Action Messages ---
-
type DeleteEmailMsg struct {
UID uint32
}
@@ -82,8 +80,33 @@ type ArchiveEmailMsg struct {
UID uint32
}
-// EmailActionDoneMsg reports the result of an action like delete or archive.
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{}