email_view.go

  1package tui
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"os"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/viewport"
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/floatpane/matcha/fetcher"
 13	"github.com/floatpane/matcha/view"
 14)
 15
 16// clearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout
 17func clearKittyGraphics() {
 18	// Delete all images: a=d (action=delete), d=A (delete all)
 19	os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
 20	os.Stdout.Sync()
 21}
 22
 23var (
 24	emailHeaderStyle   = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
 25	attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
 26)
 27
 28type EmailView struct {
 29	viewport           viewport.Model
 30	email              fetcher.Email
 31	emailIndex         int
 32	attachmentCursor   int
 33	focusOnAttachments bool
 34	accountID          string
 35	mailbox            MailboxKind
 36	disableImages      bool
 37	showImages         bool
 38	isSMIME            bool
 39	smimeTrusted       bool
 40	isEncrypted        bool
 41}
 42
 43func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
 44	isSMIME := false
 45	smimeTrusted := false
 46	isEncrypted := false
 47	var filteredAtts []fetcher.Attachment
 48
 49	for _, att := range email.Attachments {
 50		if att.Filename == "smime-status.internal" {
 51			isSMIME = att.IsSMIMESignature || att.IsSMIMEEncrypted
 52			smimeTrusted = att.SMIMEVerified
 53			isEncrypted = att.IsSMIMEEncrypted
 54		} else if att.IsSMIMESignature || att.Filename == "smime.p7s" || att.Filename == "smime.p7m" || strings.HasPrefix(att.MIMEType, "application/pkcs7") {
 55			// Skip UI rendering
 56		} else {
 57			filteredAtts = append(filteredAtts, att)
 58		}
 59	}
 60	email.Attachments = filteredAtts
 61
 62	// Pass the styles from the tui package to the view package
 63	inlineImages := inlineImagesFromAttachments(email.Attachments)
 64
 65	// Initial state for showImages matches config unless overridden later
 66	showImages := !disableImages
 67
 68	body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
 69	if err != nil {
 70		body = fmt.Sprintf("Error rendering body: %v", err)
 71	}
 72
 73	// Create header and compute heights that reduce viewport space.
 74	header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
 75	headerHeight := lipgloss.Height(header) + 2
 76
 77	attachmentHeight := 0
 78	if len(email.Attachments) > 0 {
 79		attachmentHeight = len(email.Attachments) + 2
 80	}
 81
 82	// Build viewport with initial size and set wrapped content.
 83	vp := viewport.New()
 84	vp.SetWidth(width)
 85	vp.SetHeight(height - headerHeight - attachmentHeight)
 86	wrapped := wrapBodyToWidth(body, vp.Width())
 87	vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
 88
 89	return &EmailView{
 90		viewport:      vp,
 91		email:         email,
 92		emailIndex:    emailIndex,
 93		accountID:     email.AccountID,
 94		mailbox:       mailbox,
 95		disableImages: disableImages,
 96		showImages:    showImages,
 97		isSMIME:       isSMIME,
 98		smimeTrusted:  smimeTrusted,
 99		isEncrypted:   isEncrypted,
100	}
101}
102
103func (m *EmailView) Init() tea.Cmd {
104	return nil
105}
106
107func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108	var cmd tea.Cmd
109	var cmds []tea.Cmd
110
111	switch msg := msg.(type) {
112	case tea.KeyPressMsg:
113		// Handle 'esc' key locally
114		if msg.String() == "esc" {
115			if m.focusOnAttachments {
116				m.focusOnAttachments = false
117				return m, nil
118			}
119			// Clear Kitty graphics before returning to mailbox
120			clearKittyGraphics()
121			return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
122		}
123
124		if m.focusOnAttachments {
125			switch msg.String() {
126			case "up", "k":
127				if m.attachmentCursor > 0 {
128					m.attachmentCursor--
129				}
130			case "down", "j":
131				if m.attachmentCursor < len(m.email.Attachments)-1 {
132					m.attachmentCursor++
133				}
134			case "enter":
135				if len(m.email.Attachments) > 0 {
136					selected := m.email.Attachments[m.attachmentCursor]
137					idx := m.emailIndex
138					accountID := m.accountID
139					return m, func() tea.Msg {
140						return DownloadAttachmentMsg{
141							Index:     idx,
142							Filename:  selected.Filename,
143							PartID:    selected.PartID,
144							Data:      selected.Data,
145							AccountID: accountID,
146							Mailbox:   m.mailbox,
147						}
148					}
149				}
150			case "tab":
151				m.focusOnAttachments = false
152			}
153		} else {
154			switch msg.String() {
155			case "i":
156				if view.ImageProtocolSupported() {
157					m.showImages = !m.showImages
158					clearKittyGraphics()
159
160					inlineImages := inlineImagesFromAttachments(m.email.Attachments)
161					body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
162					if err != nil {
163						body = fmt.Sprintf("Error rendering body: %v", err)
164					}
165					wrapped := wrapBodyToWidth(body, m.viewport.Width())
166					m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
167					return m, nil
168				}
169			case "r":
170				// Clear Kitty graphics before opening composer
171				clearKittyGraphics()
172				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
173			case "f":
174				// Clear Kitty graphics before opening composer
175				clearKittyGraphics()
176				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
177			case "d":
178				accountID := m.accountID
179				uid := m.email.UID
180				// Clear Kitty graphics before transitioning
181				clearKittyGraphics()
182				return m, func() tea.Msg {
183					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
184				}
185			case "a":
186				accountID := m.accountID
187				uid := m.email.UID
188				// Clear Kitty graphics before transitioning
189				clearKittyGraphics()
190				return m, func() tea.Msg {
191					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
192				}
193			case "tab":
194				if len(m.email.Attachments) > 0 {
195					m.focusOnAttachments = true
196				}
197			}
198		}
199	case tea.WindowSizeMsg:
200		header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject)
201		headerHeight := lipgloss.Height(header) + 2
202		attachmentHeight := 0
203		if len(m.email.Attachments) > 0 {
204			attachmentHeight = len(m.email.Attachments) + 2
205		}
206		// Update viewport dimensions
207		m.viewport.SetWidth(msg.Width)
208		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
209
210		// When the window size changes, wrap and clear kitty images to keep placement stable
211		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
212		body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
213		if err != nil {
214			body = fmt.Sprintf("Error rendering body: %v", err)
215		}
216		wrapped := wrapBodyToWidth(body, m.viewport.Width())
217		m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n")
218	}
219
220	m.viewport, cmd = m.viewport.Update(msg)
221	cmds = append(cmds, cmd)
222
223	return m, tea.Batch(cmds...)
224}
225
226func (m *EmailView) View() tea.View {
227	// Clear all Kitty graphics before rendering to prevent image stacking on scroll.
228	// This must be done synchronously via stdout before the frame is drawn,
229	// as escape sequences in the return string execute too late.
230	clearKittyGraphics()
231
232	smimeStatus := ""
233	if m.isEncrypted {
234		smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: 🔒 Encrypted]")
235	} else if m.isSMIME {
236		if m.smimeTrusted {
237			smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(" [S/MIME: ✅ Trusted]")
238		} else {
239			smimeStatus = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(" [S/MIME: ❌ Untrusted]")
240		}
241	}
242
243	header := fmt.Sprintf("From: %s | Subject: %s%s", m.email.From, m.email.Subject, smimeStatus)
244	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
245
246	var help string
247	if m.focusOnAttachments {
248		help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body")
249	} else {
250		shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
251		if view.ImageProtocolSupported() {
252			shortcuts = shortcuts + "• \uf03e i: toggle images"
253		}
254		help = helpStyle.Render(shortcuts)
255	}
256
257	var attachmentView string
258	if len(m.email.Attachments) > 0 {
259		var b strings.Builder
260		b.WriteString("Attachments:\n")
261		for i, attachment := range m.email.Attachments {
262			cursor := "  "
263			style := itemStyle
264			if m.focusOnAttachments && i == m.attachmentCursor {
265				cursor = "> "
266				style = selectedItemStyle
267			}
268			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
269			b.WriteString("\n")
270		}
271		attachmentView = attachmentBoxStyle.Render(b.String())
272	}
273
274	// m.viewport.View() returns a string in Bubbles v2 viewport
275	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
276}
277
278// GetAccountID returns the account ID for this email
279func (m *EmailView) GetAccountID() string {
280	return m.accountID
281}
282
283func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
284	var imgs []view.InlineImage
285	for _, att := range atts {
286		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
287			continue
288		}
289		imgs = append(imgs, view.InlineImage{
290			CID:    att.ContentID,
291			Base64: base64.StdEncoding.EncodeToString(att.Data),
292		})
293	}
294	return imgs
295}
296
297func wrapBodyToWidth(body string, width int) string {
298	return BodyStyle.Width(width).Render(body)
299}
300
301// GetEmail returns the email being viewed
302func (m *EmailView) GetEmail() fetcher.Email {
303	return m.email
304}