Detailed changes
@@ -1,10 +1,16 @@
-# Matcha 🍵
-
<div align="center">
---
<img src = "assets/logo.png" width=200 height=200>
+
+```
+ __ __
+ ____ ___ ____ _/ /______/ /_ ____ _
+ / __ '__ \/ __ '/ __/ ___/ __ \/ __ '/
+ / / / / / / /_/ / /_/ /__/ / / / /_/ /
+/_/ /_/ /_/\__,_/\__/\___/_/ /_/\__,_/
+```
---
@@ -31,7 +37,6 @@
A beautiful and functional email client for your terminal, built with Go and the charming Bubble Tea TUI library. Never leave your command line to check your inbox or send an email again!
-
</div>
@@ -101,4 +106,4 @@ Matcha is written in **Go**. To build it manually:
## License 📄
-This project is distributed under the MIT License. See the `LICENSE` file for more information.
+This project is distributed under the MIT License. See the `LICENSE` file for more information.
@@ -221,22 +221,65 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error
var textPartID string
var attachments []Attachment
+ var checkPart func(part *imap.BodyStructure, partID string)
+ checkPart = func(part *imap.BodyStructure, partID string) {
+ // Check for text content
+ if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
+ textPartID = partID
+ }
+
+ // Check for attachments using multiple methods
+ filename := ""
+ // First try the Filename() method which handles various cases
+ if fn, err := part.Filename(); err == nil && fn != "" {
+ filename = fn
+ }
+ // Fallback: check DispositionParams
+ if filename == "" {
+ if fn, ok := part.DispositionParams["filename"]; ok && fn != "" {
+ filename = fn
+ }
+ }
+ // Fallback: check Params (for name parameter)
+ if filename == "" {
+ if fn, ok := part.Params["name"]; ok && fn != "" {
+ filename = fn
+ }
+ }
+ // Fallback: check Params for filename
+ if filename == "" {
+ if fn, ok := part.Params["filename"]; ok && fn != "" {
+ filename = fn
+ }
+ }
+
+ // Add as attachment if it has a disposition or a filename (and not just plain text)
+ if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") {
+ attachments = append(attachments, Attachment{Filename: filename, PartID: partID})
+ }
+ }
+
var findParts func(*imap.BodyStructure, string)
findParts = func(bs *imap.BodyStructure, prefix string) {
+ // If this is a non-multipart message, check the body structure itself
+ if len(bs.Parts) == 0 {
+ partID := prefix
+ if partID == "" {
+ partID = "1"
+ }
+ checkPart(bs, partID)
+ return
+ }
+
+ // Iterate through parts
for i, part := range bs.Parts {
partID := fmt.Sprintf("%d", i+1)
if prefix != "" {
partID = fmt.Sprintf("%s.%d", prefix, i+1)
}
- if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" {
- textPartID = partID
- }
- if part.Disposition == "attachment" || part.Disposition == "inline" {
- if filename, ok := part.Params["filename"]; ok {
- attachments = append(attachments, Attachment{Filename: filename, PartID: partID})
- }
- }
+ checkPart(part, partID)
+
if len(part.Parts) > 0 {
findParts(part, partID)
}
@@ -167,7 +167,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.emails[msg.Index].Body = msg.Body
m.emails[msg.Index].Attachments = msg.Attachments
- emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height)
+ emailView := tui.NewEmailView(m.emails[msg.Index], msg.Index, m.width, m.height)
m.current = emailView
return m, m.current.Init()
@@ -0,0 +1,55 @@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@##%******%#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*****#@@***@%**#%**#@@%*****@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*****@@@@@@@**#*#@@%***#@@@@@@%*****#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#*#%@%%%##%@@@@%##*****###%@@@@%##%%%@%###@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@#**%%###*#@@@@@@@@@@%@@@@@@@@@@@@@@%***##%%*##@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@***#%@@#%@****#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@###**#@##@@@****@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@%*++**#%@@@@%%%%%@@@%###++++++++++++**+++++*##%%@@@%%%%@@@@@%#*+++*#@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@#***#%+*#@@@@@@@@@@#*+====%%%%%%%%%%%%%%%%%%%#===++*#@@@@@@@@@@#**%#***#@@@@@@@@@@@@@
+@@@@@@@@@@@@@@+**#%#*###@@@@@@@*+===#@@@@@@@@@@@@@@@@@@@@@@@@@@@@+====#%@@@@@@##**%@#***%@@@@@@@@@@@
+@@@@@@@@@@@@@@+*#%**##%@@@@%*+=++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*++=**%@@@@%##**%#**%@@@@@@@@@@@
+@@@@@@@@@@@@@@*##**#%@@@@%*=###@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%##*=*#@@@@%#**#%#@@@@@@@@@@@@
+@@@@@@@@@@@@%##%@@@@@@@%==*@@@@@@@@@@@#*****+++=====*%@@@@@@@@@@@@@@@@@@@@@+=+%@@@@@@@@##%@@@@@@@@@@
+@@@@@@@@@@@@##@@@@@@@@*-=#@@@@@@@@@#****+++++++++++++=--+%@%+=#@@@@@@@@@@@@@%==#@@@@@@@@#%@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@%==+@@#=+++@@@#*********++++++++++++++@@@#+*@@@@@@@@@@@@@@*=+%@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@%@@%@@@%*+=#@@#*#%%#***+##*+++++*#++++++++++##+++++#%@@@@@@@@@@@@@@@*=+#%@@@%@@%@@@@@@@@@
+@@@@@@@@#**@%##%@@*==%@@@#*#@%#++*@*=-----:--%#++++++*%--------+@@@@@@@@@@@@@@@@%==+@@@##%@#*#@@@@@@
+@@@@@@%#*#@##*#%@*=*@@@@@@%#***+#%=-:::-------+@++++##=----------%@@@@@@@@@@@@@@@@+=#@%**##@#*#@@@@@
+@@@@@@%*#%@***@@#-=#@@@@@@@@#**+#%=--:::-===-:+@++++%#=-----*@*=-%@@@@@@@@@@@@@@@@*==%@%#*#@%#*%@@@@
+@@@@@#**%%%##%@#+-#@@@@@@@@@***+#%=---::-@@@#-+@++++##=----+@@@#-%@@@@%*++**++++++%%=*#@@###@@**#@@@
+@@@@@#*@###@@@@#==%@@@@@@@%****+#%==-:---@@@@-+@=++++*@=-::----+%@@@@%*#=---=---+%+==+*@@@@###%*#@@@
+@@@@@##@%%@@@@#++@@@@@@@@@%**++++*%*==------=##*@@@@@%*%#+++++%@@@@@@%=-**+=-==**==+@++#@@@@%%@*#@@@
+@@@@@%%@@@@@@@*++@@@@@@@@@%**++++++###############@@@%*##%####**@@%*@%===++****+===+@+=#@@@@@@@%%@@@
+@@@@@@@@@@@@@%*++@@@@@@@@@%**+++++++*#####*++%#+=======@%+++++=+@++@@#+*=======-=#++@++#@@@@@@@@@@@@
+@*++++*%+*@@@%*++@@@@@@@@@%**++++++++++++++++%#++*%@@%*%#++++==*@=+%@@%*++++++++++%@@++#@@@*++++*#+#
+@*****##***@@@*++@@@@@@@@@%***++++++++++++++++*%@#-%*=%+++++=-=*@#*=#%@@@%###@@@@@@@@++#@@%*****##**
+@*#%*******@@@*++@@@@@@@@@%****+++++++++++++++++@@@@@%++++++++-+@@%=*%@@@%=%@@@@@@@@@++#@@@*%%******
+@@@@@@@@@@@@@@*++@@@@@@@@@%****+++++++++++++++++****++++++++++-+@#*=%@@@++@@@@@@@@@@@++#@@@@@@@@@@@@
+@#%@####%%*@@@*++@@@@@@@@@%*****+++++++++++++++++++++++++++++++*@**@@@@@@@@@@@@@@@@@@++#@@@#@@####%#
+@%@@%%%%@@%@@@*++@@@@@@@@@%*****+++++++++++++=======*#########%%########@@@@@@@@@@@@@+=#@@@%@@%%%%@%
+@@@@@@@@@@@@@@*=+@@@@@@@@@%*****++++++++++++=======%*++#################+++%@@@@@@@@@+=#@@@@@@@@@@@@
+@@@@@@@@@@@@@%*++@@@@@@@@@%******+++++++++++=====+%=+####**************####==@@@@@@@@+=#%@@@@@@@@@@@
+@@@@@##@@@@@@@*++@@@@@@@@@%******+++++++++=======+@=+***++========+**+=****=+@@@@@@@@+=#%@@@@@@*#@@@
+@@@@@**@##%@@@@#==%@@@@@@@%*****+++++++++####+===*@%*++==+++++++++++++==+++**@@@@@@%=+*@@@@%##@*#@@@
+@@@@@#*#%#*#%@@%*=%@@@@@@@%******++++++====++%@%%@@@%%%#***************+***%@@@@@@@%=*%@@%**%%**#@@@
+@@@@@@%+%@@#**@@#=-*@@@@@#********++++*++=======----*@%%#****************#@+==%@@@+-=%@@**#@@%+%@@@@
+@@@@@@%**%@#**%%%#++#@@@@%********++++%###*=======+*%@%##**************+*#@*++%@%#==#@@#**#@#**%@@@@
+@@@@@@@@#*#%#*+#@@#+=+%@@%********++++*###%%%%%%%%%@@%%##**************+*##%%%@#+=+*@@%+*#%#*#@@@@@@
+@@@@@@@@@@@*%%*%@@@%#=#@@#********++++++***********@@@%%%%************+**#@@@@@*=#@@@@%*%#*@@@@@@@@@
+@@@@@@@@@@@@%%@@@@@@@+==#**********++++++=+*********#@@%%%%####*******##@@@@@#==*@@@@@@@%%@@@@@@@@@@
+@@@@@@@@@@@@%#@@@@@@@%*+-=+*********+++++========+***##@@@@@%%%%%%%%%%@@@@@*+=+#%@@@@@@@#%@@@@@@@@@@
+@@@@@@@@@@@@#+****%@@@@%*+==********+++++=========+****##@####%%#####%@@@%+=+#%@@@@%++**+%@@@@@@@@@@
+@@@@@@@@@@@@@%+**+++*@@@@%*====*******+++===========******@@@@@@@@@@@@%+===*#@@@%*+++**+%@@@@@@@@@@@
+@@@@@@@@@@@@@%***%#*++#@@@%##+==+*******+++==========+**********#@@%%*==+*#@@@@#++*#%***%@@@@@@@@@@@
+@@@@@@@@@@@@@@%#**#%%+*#@@@@@@%#*+==++*****+============++******+++=+*%%@@@@@%*+*%##***@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@%*++*#%##@@@@@@@@@@@#*+==========================***@@@@@@@@@@%*#%#*++*#@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@%#**+*%@@@@@@%#*%@@@@@#**********************###@@@@@%*#@@@@@@@#*+*##@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@%%%%%#%@@%%%##***#%@@@@@@@@@@@@@@@@@@@@@@@@@@%****##%#%@@%%%%%%@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@%@@@#*#%%###*@@@@@@@%%########%@@@@@@@%*###%%**%@@@%@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@**#@@@@*#@@@@@##%*****#%*#@@@@@##@@@@#*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*#***@@%%@@@#**@%##%%#*#@@@%%%%***##%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%%%%%%%%@@##%###%##%*#@@%####%%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%#********%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@ -14,7 +14,7 @@ var (
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"))
+ selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
)
type Choice struct {
@@ -12,14 +12,14 @@ import (
// Styles for the UI
var (
- focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+ focusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
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)
+ emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style
)
@@ -19,11 +19,12 @@ var (
type EmailView struct {
viewport viewport.Model
email fetcher.Email
+ emailIndex int
attachmentCursor int
focusOnAttachments bool
}
-func NewEmailView(email fetcher.Email, width, height int) *EmailView {
+func NewEmailView(email fetcher.Email, emailIndex, width, height int) *EmailView {
// Pass the styles from the tui package to the view package
body, err := view.ProcessBody(email.Body, H1Style, H2Style, BodyStyle)
if err != nil {
@@ -42,8 +43,9 @@ func NewEmailView(email fetcher.Email, width, height int) *EmailView {
vp.SetContent(body)
return &EmailView{
- viewport: vp,
- email: email,
+ viewport: vp,
+ email: email,
+ emailIndex: emailIndex,
}
}
@@ -79,8 +81,14 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "enter":
if len(m.email.Attachments) > 0 {
selected := m.email.Attachments[m.attachmentCursor]
+ idx := m.emailIndex
return m, func() tea.Msg {
- return DownloadAttachmentMsg{Filename: selected.Filename, Data: selected.Data}
+ return DownloadAttachmentMsg{
+ Index: idx,
+ Filename: selected.Filename,
+ PartID: selected.PartID,
+ Data: selected.Data,
+ }
}
}
case "tab":
@@ -28,7 +28,7 @@ func TestEmailViewUpdate(t *testing.T) {
}
t.Run("Focus on attachments", func(t *testing.T) {
- emailView := NewEmailView(emailWithAttachments, 80, 24)
+ emailView := NewEmailView(emailWithAttachments, 0, 80, 24)
if emailView.focusOnAttachments {
t.Error("focusOnAttachments should be initially false")
}
@@ -50,7 +50,7 @@ func TestEmailViewUpdate(t *testing.T) {
})
t.Run("No focus on attachments when there are none", func(t *testing.T) {
- emailView := NewEmailView(emailWithoutAttachments, 80, 24)
+ emailView := NewEmailView(emailWithoutAttachments, 0, 80, 24)
if emailView.focusOnAttachments {
t.Error("focusOnAttachments should be initially false")
}
@@ -63,7 +63,7 @@ func TestEmailViewUpdate(t *testing.T) {
})
t.Run("Navigate attachments", func(t *testing.T) {
- emailView := NewEmailView(emailWithAttachments, 80, 24)
+ emailView := NewEmailView(emailWithAttachments, 0, 80, 24)
// Focus on attachments
model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
emailView = model.(*EmailView)
@@ -95,7 +95,7 @@ func TestEmailViewUpdate(t *testing.T) {
})
t.Run("Download attachment", func(t *testing.T) {
- emailView := NewEmailView(emailWithAttachments, 80, 24)
+ emailView := NewEmailView(emailWithAttachments, 0, 80, 24)
// Focus on attachments
model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab})
emailView = model.(*EmailView)
@@ -121,7 +121,7 @@ func TestEmailViewUpdate(t *testing.T) {
})
t.Run("Reply to email", func(t *testing.T) {
- emailView := NewEmailView(emailWithAttachments, 80, 24)
+ emailView := NewEmailView(emailWithAttachments, 0, 80, 24)
_, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
if cmd == nil {
@@ -13,8 +13,8 @@ import (
var (
filePickerItemStyle = lipgloss.NewStyle().PaddingLeft(2)
- filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205"))
- directoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
+ filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
+ directoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
)
type FilePicker struct {
@@ -70,7 +70,7 @@ func NewInbox(emails []fetcher.Email) *Inbox {
l.Title = "Inbox"
l.SetShowStatusBar(true)
l.SetFilteringEnabled(true)
- l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
+ l.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
l.Styles.PaginationStyle = paginationStyle
l.Styles.HelpStyle = inboxHelpStyle
l.SetStatusBarItemName("email", "emails")
@@ -8,10 +8,19 @@ import (
"github.com/charmbracelet/lipgloss"
)
+// ASCII logo for Matcha displayed during loading screens
+const asciiLogo = `
+ __ __
+ ____ ___ ____ _/ /______/ /_ ____ _
+ / __ '__ \/ __ '/ __/ ___/ __ \/ __ '/
+ / / / / / / /_/ / /_/ /__/ / / / /_/ /
+/_/ /_/ /_/\__,_/\__/\___/_/ /_/\__,_/
+`
+
var (
DialogBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
- BorderForeground(lipgloss.Color("#874BFD")).
+ BorderForeground(lipgloss.Color("#25A065")).
Padding(1, 0).
BorderTop(true).
BorderLeft(true).
@@ -20,15 +29,15 @@ var (
HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
- InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
+ InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
H1Style = lipgloss.NewStyle().
- Foreground(lipgloss.Color("205")).
+ Foreground(lipgloss.Color("42")).
Bold(true).
Align(lipgloss.Center)
H2Style = lipgloss.NewStyle().
- Foreground(lipgloss.Color("205")).
+ Foreground(lipgloss.Color("42")).
Bold(false). // Less bold
Align(lipgloss.Center)
@@ -47,7 +56,7 @@ type Status struct {
func NewStatus(msg string) Status {
s := spinner.New()
s.Spinner = spinner.Dot
- s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+ s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
return Status{spinner: s, message: msg}
}
@@ -60,5 +69,10 @@ func (m Status) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Status) View() string {
- return fmt.Sprintf("\n\n %s %s\n\n", m.spinner.View(), m.message)
+ logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
+ styledLogo := logoStyle.Render(asciiLogo)
+
+ spinnerLine := fmt.Sprintf(" %s %s", m.spinner.View(), m.message)
+
+ return fmt.Sprintf("%s\n%s\n\n", styledLogo, spinnerLine)
}