Skip to content
Merged
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 @@ -26,6 +26,7 @@
"delete": "d",
"archive": "a",
"toggle_images": "i",
"load_remote_images": "I",
"rsvp_accept": "1",
"rsvp_decline": "2",
"rsvp_tentative": "3",
Expand Down
26 changes: 14 additions & 12 deletions config/keybinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type EmailKeys struct {
Delete string `json:"delete"`
Archive string `json:"archive"`
ToggleImages string `json:"toggle_images"`
LoadRemoteImages string `json:"load_remote_images"`
RsvpAccept string `json:"rsvp_accept"`
RsvpDecline string `json:"rsvp_decline"`
RsvpTentative string `json:"rsvp_tentative"`
Expand Down Expand Up @@ -136,18 +137,19 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
"prev_tab": kb.Inbox.PrevTab,
},
"email": {
"reply": kb.Email.Reply,
"reply_all": kb.Email.ReplyAll,
"forward": kb.Email.Forward,
keyDelete: kb.Email.Delete,
"archive": kb.Email.Archive,
"toggle_images": kb.Email.ToggleImages,
"rsvp_accept": kb.Email.RsvpAccept,
"rsvp_decline": kb.Email.RsvpDecline,
"rsvp_tentative": kb.Email.RsvpTentative,
"focus_attachments": kb.Email.FocusAttachments,
"apply_patch": kb.Email.ApplyPatch,
"send_patch": kb.Email.SendPatch,
"reply": kb.Email.Reply,
"reply_all": kb.Email.ReplyAll,
"forward": kb.Email.Forward,
keyDelete: kb.Email.Delete,
"archive": kb.Email.Archive,
"toggle_images": kb.Email.ToggleImages,
"load_remote_images": kb.Email.LoadRemoteImages,
"rsvp_accept": kb.Email.RsvpAccept,
"rsvp_decline": kb.Email.RsvpDecline,
"rsvp_tentative": kb.Email.RsvpTentative,
"focus_attachments": kb.Email.FocusAttachments,
"apply_patch": kb.Email.ApplyPatch,
"send_patch": kb.Email.SendPatch,
},
"composer": {
"undo_send": kb.Composer.UndoSend,
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/Features/Images.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Image Protocol Compatibility

::: tip
This feature is optional. To disable images, go to Settings > General > Disable Image Display: ON, you can also always turn it on for 1 email by pressing `i`. If you worry about security, we use a sandboxed subprocess for decoding on Linux (via Landlock + seccomp), via [termimage](https://github.com/floatpane/termimage), and we never execute any code from emails.
This feature is optional. To disable image display, go to Settings > General > Disable Image Display: ON. Press `i` to toggle image display for one email. Matcha blocks remote images by default; press `I` while viewing an email to load its remote images. This permission lasts only for the current message view. Image decoding uses a sandboxed subprocess on Linux (via Landlock + seccomp) through [termimage](https://github.com/floatpane/termimage).
:::
Matcha supports modern terminal image protocols for displaying images directly in your terminal. This allows for rich email viewing with inline images, including those embedded in HTML emails (CID references), remote images, and base64-encoded data URIs.

Expand Down Expand Up @@ -40,7 +40,7 @@ Native support is included for the iTerm2 inline image protocol.
## Features

- **Inline Email Images**: Display images embedded in HTML emails.
- **Remote Image Fetching**: Automatically fetches and displays remote images from URLs.
- **Remote Image Fetching**: Fetches remote images only after you press `I` for the current message.
- **Data URI Support**: Renders base64-encoded inline images.
- **Smart Fallback**: Gracefully falls back to clickable links when images aren't supported.

Expand Down
2 changes: 2 additions & 0 deletions docs/docs/Features/Keybinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Plain text, not encrypted. Edit with any text editor. Restart matcha to apply ch
"delete": "d",
"archive": "a",
"toggle_images": "i",
"load_remote_images": "I",
"rsvp_accept": "1",
"rsvp_decline": "2",
"rsvp_tentative": "3",
Expand Down Expand Up @@ -81,6 +82,7 @@ rely on the palette for less-common actions instead of memorizing every key.

When viewing an email, the palette also includes:

- **Load remote images** — fetches remote content for the current message only.
- **Export as…** — opens a second palette to choose the format, then exports the
email with full headers and metadata. Opens a system save dialog on macOS, or
a TUI file browser on other platforms. The format choices are:
Expand Down
5 changes: 5 additions & 0 deletions internal/palette/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,17 @@ func BuildCommands(current tea.Model, folderInbox *tui.FolderInbox, cfg *config.
tui.PaletteCommand{Title: "Toggle CC/BCC fields", Keywords: "cc bcc carbon copy blind show hide", Action: func() tea.Msg { return tui.ToggleCcBccMsg{} }},
)
case *tui.EmailView:
loadRemoteImagesKey := kb.Email.LoadRemoteImages
if loadRemoteImagesKey == "" {
loadRemoteImagesKey = "I"
}
cmds = append(cmds,
tui.PaletteCommand{Title: "Reply", Hint: kb.Email.Reply, Keywords: "respond answer", Action: KeyAction(kb.Email.Reply)},
tui.PaletteCommand{Title: "Forward", Hint: kb.Email.Forward, Keywords: "fwd send on", Action: KeyAction(kb.Email.Forward)},
tui.PaletteCommand{Title: "Archive email", Hint: kb.Email.Archive, Keywords: "file store", Action: KeyAction(kb.Email.Archive)},
tui.PaletteCommand{Title: "Delete email", Hint: kb.Email.Delete, Keywords: "trash remove", Action: KeyAction(kb.Email.Delete)},
tui.PaletteCommand{Title: "Toggle images", Hint: kb.Email.ToggleImages, Keywords: "pictures show hide", Action: KeyAction(kb.Email.ToggleImages)},
tui.PaletteCommand{Title: "Load remote images", Hint: loadRemoteImagesKey, Keywords: "remote content privacy tracking", Action: KeyAction(loadRemoteImagesKey)},
)
if !v.IsPreviewMode() && splitEnabled {
cmds = append(cmds,
Expand Down
138 changes: 102 additions & 36 deletions tui/email_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"sync/atomic"
"time"

"charm.land/bubbles/v2/viewport"
Expand Down Expand Up @@ -34,10 +35,18 @@ func ClearKittyGraphics() {
}

var (
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)
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)
remoteImageRequestID atomic.Uint64
)

type remoteImagesLoadedMsg struct {
requestID uint64
body string
placements []view.ImagePlacement
err error
}

// BodyTransformer, if set, post-processes the rendered email body before it is
// placed in the viewport. main.go wires this up to the plugin manager so that
// plugins registered on the "email_body_render" hook can rewrite, recolor, or
Expand All @@ -52,32 +61,37 @@ func applyBodyTransform(body string, email fetcher.Email) string {
}

type EmailView struct {
viewport viewport.Model
email fetcher.Email
emailIndex int
attachmentCursor int
focusOnAttachments bool
accountID string
mailbox MailboxKind
disableImages bool
showImages bool
isSMIME bool
smimeTrusted bool
isEncrypted bool
isPGP bool
pgpTrusted bool
isPGPEncrypted bool
imagePlacements []view.ImagePlacement
pluginStatus string
pluginKeyBindings []PluginKeyBinding
hasCalendarInvite bool
calendarEvent *calendar.Event
originalICSData []byte
isPreviewMode bool
columnOffset int // horizontal offset for image rendering in split pane
rowOffset int // vertical offset for image rendering in split pane (vertical layout)
isPatch bool
patchInfo *view.PatchInfo
viewport viewport.Model
email fetcher.Email
emailIndex int
attachmentCursor int
focusOnAttachments bool
accountID string
mailbox MailboxKind
disableImages bool
showImages bool
showRemoteImages bool
remoteImagesLoading bool
remoteLoadID uint64
remoteBody string
remotePlacements []view.ImagePlacement
isSMIME bool
smimeTrusted bool
isEncrypted bool
isPGP bool
pgpTrusted bool
isPGPEncrypted bool
imagePlacements []view.ImagePlacement
pluginStatus string
pluginKeyBindings []PluginKeyBinding
hasCalendarInvite bool
calendarEvent *calendar.Event
originalICSData []byte
isPreviewMode bool
columnOffset int // horizontal offset for image rendering in split pane
rowOffset int // vertical offset for image rendering in split pane (vertical layout)
isPatch bool
patchInfo *view.PatchInfo
}

func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
Expand Down Expand Up @@ -260,6 +274,21 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case tea.WindowSizeMsg:
m.handleWindowSize(msg)
case remoteImagesLoadedMsg:
if !m.remoteImagesLoading || msg.requestID != m.remoteLoadID {
return m, nil
}
m.remoteImagesLoading = false
m.showRemoteImages = true
m.remoteBody = msg.body
m.remotePlacements = msg.placements
if msg.err != nil {
m.remoteBody = fmt.Sprintf("Error rendering body: %v", msg.err)
}
if m.showImages {
m.regenerateBody()
}
return m, nil
}

m.viewport, cmd = m.viewport.Update(msg)
Expand Down Expand Up @@ -330,6 +359,13 @@ func (m *EmailView) handleAttachmentFocusKey(msg tea.KeyPressMsg) (bool, func()
return false, nil
}

func (m *EmailView) loadRemoteImagesKey() string {
if config.Keybinds.Email.LoadRemoteImages != "" {
return config.Keybinds.Email.LoadRemoteImages
}
return "I"
}

// handleEmailBodyKey handles key presses when the email body has focus.
func (m *EmailView) handleEmailBodyKey(msg tea.KeyPressMsg) (bool, func() (tea.Model, tea.Cmd)) {
kb := config.Keybinds
Expand All @@ -341,6 +377,23 @@ func (m *EmailView) handleEmailBodyKey(msg tea.KeyPressMsg) (bool, func() (tea.M
m.regenerateBody()
return true, func() (tea.Model, tea.Cmd) { return m, nil }
}
case m.loadRemoteImagesKey():
if view.ImageProtocolSupported() && !m.showRemoteImages && !m.remoteImagesLoading {
m.showImages = true
m.remoteImagesLoading = true
m.remoteLoadID = remoteImageRequestID.Add(1)
ClearKittyGraphics()
m.regenerateBody()
body, mimeType := m.email.Body, m.email.BodyMIMEType
inline := inlineImagesFromAttachments(m.email.Attachments)
requestID := m.remoteLoadID
return true, func() (tea.Model, tea.Cmd) {
return m, func() tea.Msg {
rendered, placements, err := view.ProcessBodyWithInlineRemote(body, mimeType, inline, H1Style, H2Style, BodyStyle, false, true)
return remoteImagesLoadedMsg{requestID: requestID, body: rendered, placements: placements, err: err}
}
}
}
case kb.Email.Reply, kb.Email.ReplyAll, kb.Email.Forward:
if composeMsg, ok := m.handleComposeAction(msg.String()); ok {
return true, func() (tea.Model, tea.Cmd) {
Expand Down Expand Up @@ -426,14 +479,24 @@ func rsvpResponseFromKey(key string, kb config.KeybindsConfig) string {
return ""
}

// regenerateBody re-renders the email body after a state change such as
// toggling image display.
func (m *EmailView) regenerateBody() {
// renderedBody returns the already downloaded remote images when available.
// Ordinary UI updates only render inline images and never access the network.
func (m *EmailView) renderedBody() (string, []view.ImagePlacement) {
if m.showImages && m.showRemoteImages {
return m.remoteBody, m.remotePlacements
}
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)
}
return body, placements
}

// regenerateBody re-renders the email body after a state change such as
// toggling image display.
func (m *EmailView) regenerateBody() {
body, placements := m.renderedBody()
body = applyBodyTransform(body, m.email)
m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
Expand All @@ -453,11 +516,7 @@ func (m *EmailView) handleWindowSize(msg tea.WindowSizeMsg) {
m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)

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, placements := m.renderedBody()
body = applyBodyTransform(body, m.email)
if m.isPatch {
if patchBody, ok := view.RenderPatchBody(m.email.Body, m.email.BodyMIMEType, m.email.Subject, m.email.From, m.viewport.Width()); ok {
Expand Down Expand Up @@ -562,6 +621,13 @@ func (m *EmailView) renderHelp() string {
if view.ImageProtocolSupported() {
shortcuts.WriteString(helpSeparator)
shortcuts.WriteString(helpItem("\uf03e", kbKey(kb.Email.ToggleImages, "i"), "toggle images").String())
if m.remoteImagesLoading {
shortcuts.WriteString(helpSeparator)
shortcuts.WriteString("loading remote images…")
} else if !m.showRemoteImages {
shortcuts.WriteString(helpSeparator)
shortcuts.WriteString(helpItem("\uf03e", m.loadRemoteImagesKey(), "load remote images").String())
}
}
for _, pk := range m.pluginKeyBindings {
shortcuts.WriteString(" • ")
Expand Down
Loading
Loading