Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/default_keybinds.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"delete": "d",
"archive": "a",
"toggle_images": "i",
"toggle_quotes": "q",
"rsvp_accept": "1",
"rsvp_decline": "2",
"rsvp_tentative": "3",
Expand Down
2 changes: 2 additions & 0 deletions config/keybinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type EmailKeys struct {
Delete string `json:"delete"`
Archive string `json:"archive"`
ToggleImages string `json:"toggle_images"`
ToggleQuotes string `json:"toggle_quotes"`
RsvpAccept string `json:"rsvp_accept"`
RsvpDecline string `json:"rsvp_decline"`
RsvpTentative string `json:"rsvp_tentative"`
Expand Down Expand Up @@ -131,6 +132,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
keyDelete: kb.Email.Delete,
"archive": kb.Email.Archive,
"toggle_images": kb.Email.ToggleImages,
"toggle_quotes": kb.Email.ToggleQuotes,
"rsvp_accept": kb.Email.RsvpAccept,
"rsvp_decline": kb.Email.RsvpDecline,
"rsvp_tentative": kb.Email.RsvpTentative,
Expand Down
30 changes: 30 additions & 0 deletions send_test_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Send a test email with quoted text to test the collapse feature."""
import smtplib
from email.mime.text import MIMEText
import getpass

EMAIL = "shabha2004@gmail.com"

body = """\
Hey, this is my reply!

On Mon, Jun 23, 2026 at 2:00 AM you@example.com wrote:
> This is the original message.
> It has multiple lines.
> Third line of quoted text.
> Fourth line of quoted text.
> Fifth line to make it obvious.
"""

msg = MIMEText(body)
msg["Subject"] = "Test quote collapse"
msg["From"] = EMAIL
msg["To"] = EMAIL

password = getpass.getpass("Enter Gmail App Password: ")

with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
server.login(EMAIL, password)
server.send_message(msg)
print("Email sent successfully!")
48 changes: 28 additions & 20 deletions tui/email_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type EmailView struct {
mailbox MailboxKind
disableImages bool
showImages bool
showQuotedText bool
isSMIME bool
smimeTrusted bool
isEncrypted bool
Expand Down Expand Up @@ -138,6 +139,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma
}
body = applyBodyTransform(body, email)

// Collapse quoted text by default to reduce clutter
body = view.CollapseQuotedText(body)

// Create header and compute heights that reduce viewport space.
header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
headerHeight := lipgloss.Height(header) + 2
Expand Down Expand Up @@ -194,6 +198,23 @@ func (m *EmailView) Init() tea.Cmd {
return nil
}

// refreshBody re-renders the email body with current display settings
// (image visibility, quote collapse state) and updates the viewport content.
func (m *EmailView) refreshBody() {
inlineImages := inlineImagesFromAttachments(m.email.Attachments)
body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
body = applyBodyTransform(body, m.email)
if !m.showQuotedText {
body = view.CollapseQuotedText(body)
}
m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent(wrapped + "\n")
}

func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
cmds := make([]tea.Cmd, 0, 1)
Expand Down Expand Up @@ -249,18 +270,13 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if view.ImageProtocolSupported() {
m.showImages = !m.showImages
ClearKittyGraphics()

inlineImages := inlineImagesFromAttachments(m.email.Attachments)
body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
body = applyBodyTransform(body, m.email)
m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent(wrapped + "\n")
m.refreshBody()
return m, nil
}
case kb.Email.ToggleQuotes:
m.showQuotedText = !m.showQuotedText
m.refreshBody()
return m, nil
case kb.Email.Reply:
// Clear Kitty graphics before opening composer
ClearKittyGraphics()
Expand Down Expand Up @@ -327,15 +343,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

// When the window size changes, wrap and clear kitty images to keep placement stable
ClearKittyGraphics()
inlineImages := inlineImagesFromAttachments(m.email.Attachments)
body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
if err != nil {
body = fmt.Sprintf("Error rendering body: %v", err)
}
body = applyBodyTransform(body, m.email)
m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent(wrapped + "\n")
m.refreshBody()
}

m.viewport, cmd = m.viewport.Update(msg)
Expand Down Expand Up @@ -385,7 +393,7 @@ func (m *EmailView) View() tea.View {
help = helpStyle.Render(helpText)
} else {
var shortcuts strings.Builder
shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox • q: toggle quotes")
if view.ImageProtocolSupported() {
shortcuts.WriteString("• \uf03e i: toggle images")
}
Expand Down
59 changes: 59 additions & 0 deletions view/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,65 @@ func quoteHeaderStyle() lipgloss.Style {
Foreground(theme.ActiveTheme.Secondary)
}

func collapsedQuoteStyle() lipgloss.Style {
return lipgloss.NewStyle().
Foreground(theme.ActiveTheme.Secondary).
Italic(true)
}

// CollapseQuotedText takes a rendered email body and replaces styled quote
// boxes (produced by renderQuoteBox / styleQuotedReplies) with a single-line
// collapsed indicator. Quote boxes are identified by the rounded-border
// characters (╭/╰) used by quoteBoxStyle. Each contiguous box is replaced
// with a "▶ quoted text hidden" line.
func CollapseQuotedText(body string) string {
lines := strings.Split(body, "\n")
var result []string
inQuoteBox := false
var from string

// The rounded border top-left is ╭ and bottom-left is ╰.
// quoteBoxStyle uses lipgloss.RoundedBorder() which produces these.
for _, line := range lines {
trimmed := strings.TrimSpace(line)

// Detect the top of a quote box
if !inQuoteBox && strings.Contains(trimmed, "╭") && strings.Contains(trimmed, "╮") {
inQuoteBox = true
from = ""
continue
}

if inQuoteBox {
// Try to extract the "from" header from the first content line
if from == "" {
// Strip border chars (│) and whitespace to get content
content := strings.TrimSpace(strings.Trim(trimmed, "│"))
if content != "" && !strings.Contains(trimmed, "╰") {
from = content
}
}

// Detect the bottom of a quote box
if strings.Contains(trimmed, "╰") && strings.Contains(trimmed, "╯") {
inQuoteBox = false
var label string
if from != "" {
label = fmt.Sprintf("▶ quoted text from %s (press q to expand)", from)
} else {
label = "▶ quoted text hidden (press q to expand)"
}
result = append(result, collapsedQuoteStyle().Render(label))
}
continue
}

result = append(result, line)
}

return strings.Join(result, "\n")
}

// styleQuotedReplies detects quoted reply sections and styles them in a box
func styleQuotedReplies(text string) string {
lines := strings.Split(text, "\n")
Expand Down
Loading