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/theme"
 14	"github.com/floatpane/matcha/view"
 15)
 16
 17// ClearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout.
 18func ClearKittyGraphics() {
 19	// Delete all images: a=d (action=delete), d=A (delete all)
 20	os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
 21	os.Stdout.Sync()
 22}
 23
 24var (
 25	emailHeaderStyle   = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
 26	attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
 27)
 28
 29type EmailView struct {
 30	viewport           viewport.Model
 31	email              fetcher.Email
 32	emailIndex         int
 33	attachmentCursor   int
 34	focusOnAttachments bool
 35	accountID          string
 36	mailbox            MailboxKind
 37	disableImages      bool
 38	showImages         bool
 39	isSMIME            bool
 40	smimeTrusted       bool
 41	isEncrypted        bool
 42	imagePlacements    []view.ImagePlacement
 43	pluginStatus       string
 44}
 45
 46func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
 47	isSMIME := false
 48	smimeTrusted := false
 49	isEncrypted := false
 50	var filteredAtts []fetcher.Attachment
 51
 52	for _, att := range email.Attachments {
 53		if att.Filename == "smime-status.internal" {
 54			isSMIME = att.IsSMIMESignature || att.IsSMIMEEncrypted
 55			smimeTrusted = att.SMIMEVerified
 56			isEncrypted = att.IsSMIMEEncrypted
 57		} else if att.IsSMIMESignature || att.Filename == "smime.p7s" || att.Filename == "smime.p7m" || strings.HasPrefix(att.MIMEType, "application/pkcs7") {
 58			// Extract S/MIME status from detached signature attachments
 59			if att.IsSMIMESignature && !isSMIME {
 60				isSMIME = true
 61				smimeTrusted = att.SMIMEVerified
 62			}
 63			// Skip UI rendering
 64		} else {
 65			filteredAtts = append(filteredAtts, att)
 66		}
 67	}
 68	email.Attachments = filteredAtts
 69
 70	// Pass the styles from the tui package to the view package
 71	inlineImages := inlineImagesFromAttachments(email.Attachments)
 72
 73	// Initial state for showImages matches config unless overridden later
 74	showImages := !disableImages
 75
 76	body, placements, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
 77	if err != nil {
 78		body = fmt.Sprintf("Error rendering body: %v", err)
 79	}
 80
 81	// Create header and compute heights that reduce viewport space.
 82	header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
 83	headerHeight := lipgloss.Height(header) + 2
 84
 85	attachmentHeight := 0
 86	if len(email.Attachments) > 0 {
 87		attachmentHeight = len(email.Attachments) + 2
 88	}
 89
 90	// Build viewport with initial size and set wrapped content.
 91	vp := viewport.New()
 92	vp.SetWidth(width)
 93	vp.SetHeight(height - headerHeight - attachmentHeight)
 94	wrapped := wrapBodyToWidth(body, vp.Width())
 95	vp.SetContent(wrapped + "\n")
 96
 97	return &EmailView{
 98		viewport:        vp,
 99		email:           email,
100		emailIndex:      emailIndex,
101		accountID:       email.AccountID,
102		mailbox:         mailbox,
103		disableImages:   disableImages,
104		showImages:      showImages,
105		isSMIME:         isSMIME,
106		smimeTrusted:    smimeTrusted,
107		isEncrypted:     isEncrypted,
108		imagePlacements: placements,
109	}
110}
111
112func (m *EmailView) Init() tea.Cmd {
113	return nil
114}
115
116func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117	var cmd tea.Cmd
118	var cmds []tea.Cmd
119
120	switch msg := msg.(type) {
121	case tea.KeyPressMsg:
122		// Handle 'esc' key locally
123		if msg.String() == "esc" {
124			if m.focusOnAttachments {
125				m.focusOnAttachments = false
126				return m, nil
127			}
128			// Clear Kitty graphics before returning to mailbox
129			ClearKittyGraphics()
130			return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
131		}
132
133		if m.focusOnAttachments {
134			switch msg.String() {
135			case "up", "k":
136				if m.attachmentCursor > 0 {
137					m.attachmentCursor--
138				}
139			case "down", "j":
140				if m.attachmentCursor < len(m.email.Attachments)-1 {
141					m.attachmentCursor++
142				}
143			case "enter":
144				if len(m.email.Attachments) > 0 {
145					selected := m.email.Attachments[m.attachmentCursor]
146					idx := m.emailIndex
147					accountID := m.accountID
148					return m, func() tea.Msg {
149						return DownloadAttachmentMsg{
150							Index:     idx,
151							Filename:  selected.Filename,
152							PartID:    selected.PartID,
153							Data:      selected.Data,
154							AccountID: accountID,
155							Mailbox:   m.mailbox,
156						}
157					}
158				}
159			case "tab":
160				m.focusOnAttachments = false
161			}
162		} else {
163			switch msg.String() {
164			case "i":
165				if view.ImageProtocolSupported() {
166					m.showImages = !m.showImages
167					ClearKittyGraphics()
168
169					inlineImages := inlineImagesFromAttachments(m.email.Attachments)
170					body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
171					if err != nil {
172						body = fmt.Sprintf("Error rendering body: %v", err)
173					}
174					m.imagePlacements = placements
175					wrapped := wrapBodyToWidth(body, m.viewport.Width())
176					m.viewport.SetContent(wrapped + "\n")
177					return m, nil
178				}
179			case "r":
180				// Clear Kitty graphics before opening composer
181				ClearKittyGraphics()
182				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
183			case "f":
184				// Clear Kitty graphics before opening composer
185				ClearKittyGraphics()
186				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
187			case "d":
188				accountID := m.accountID
189				uid := m.email.UID
190				// Clear Kitty graphics before transitioning
191				ClearKittyGraphics()
192				return m, func() tea.Msg {
193					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
194				}
195			case "a":
196				accountID := m.accountID
197				uid := m.email.UID
198				// Clear Kitty graphics before transitioning
199				ClearKittyGraphics()
200				return m, func() tea.Msg {
201					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
202				}
203			case "tab":
204				if len(m.email.Attachments) > 0 {
205					m.focusOnAttachments = true
206				}
207			}
208		}
209	case tea.WindowSizeMsg:
210		header := fmt.Sprintf("To: %s\nFrom: %s\nSubject: %s ", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject)
211		headerHeight := lipgloss.Height(header) + 2
212		attachmentHeight := 0
213		if len(m.email.Attachments) > 0 {
214			attachmentHeight = len(m.email.Attachments) + 2
215		}
216		// Update viewport dimensions
217		m.viewport.SetWidth(msg.Width)
218		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
219
220		// When the window size changes, wrap and clear kitty images to keep placement stable
221		ClearKittyGraphics()
222		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
223		body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
224		if err != nil {
225			body = fmt.Sprintf("Error rendering body: %v", err)
226		}
227		m.imagePlacements = placements
228		wrapped := wrapBodyToWidth(body, m.viewport.Width())
229		m.viewport.SetContent(wrapped + "\n")
230	}
231
232	m.viewport, cmd = m.viewport.Update(msg)
233	cmds = append(cmds, cmd)
234
235	return m, tea.Batch(cmds...)
236}
237
238func (m *EmailView) View() tea.View {
239	// Clear image placements (but keep uploaded image data in terminal memory)
240	// before re-rendering to prevent stacking on scroll. Uses d=a (delete all
241	// placements) instead of d=A (delete all including data) so that images
242	// can be re-displayed by ID without re-uploading.
243	os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
244	os.Stdout.Sync()
245
246	smimeStatus := ""
247	if m.isEncrypted {
248		smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
249	} else if m.isSMIME {
250		if m.smimeTrusted {
251			smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
252		} else {
253			smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
254		}
255	}
256
257	header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, smimeStatus)
258	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
259
260	var help string
261	if m.focusOnAttachments {
262		helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
263		if m.pluginStatus != "" {
264			helpText += " • " + m.pluginStatus
265		}
266		help = helpStyle.Render(helpText)
267	} else {
268		shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
269		if view.ImageProtocolSupported() {
270			shortcuts = shortcuts + "• \uf03e i: toggle images"
271		}
272		if m.pluginStatus != "" {
273			shortcuts += " • " + m.pluginStatus
274		}
275		help = helpStyle.Render(shortcuts)
276	}
277
278	var attachmentView string
279	if len(m.email.Attachments) > 0 {
280		var b strings.Builder
281		b.WriteString("Attachments:\n")
282		for i, attachment := range m.email.Attachments {
283			cursor := "  "
284			style := itemStyle
285			if m.focusOnAttachments && i == m.attachmentCursor {
286				cursor = "> "
287				style = selectedItemStyle
288			}
289			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
290			b.WriteString("\n")
291		}
292		attachmentView = attachmentBoxStyle.Render(b.String())
293	}
294
295	// Render visible images directly to stdout. Bubbletea v2's ultraviolet
296	// renderer uses a cell-based model that cannot pass through graphics
297	// protocol escape sequences, so we write them out-of-band.
298	if m.showImages && len(m.imagePlacements) > 0 {
299		headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
300		yOffset := m.viewport.YOffset()
301		vpHeight := m.viewport.Height()
302
303		for i := range m.imagePlacements {
304			p := &m.imagePlacements[i]
305			// Only render if the image's top line is within the viewport.
306			// We can't partially clip images scrolled off the top (Kitty
307			// always renders from the top-left), so we hide them once
308			// their start line scrolls above the viewport.
309			if p.Line >= yOffset && p.Line < yOffset+vpHeight {
310				screenRow := headerLines + (p.Line - yOffset)
311				view.RenderImageToStdout(p, screenRow)
312			}
313		}
314	}
315
316	// m.viewport.View() returns a string in Bubbles v2 viewport
317	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
318}
319
320// GetAccountID returns the account ID for this email
321func (m *EmailView) GetAccountID() string {
322	return m.accountID
323}
324
325// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
326func (m *EmailView) SetPluginStatus(status string) {
327	m.pluginStatus = status
328}
329
330func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
331	var imgs []view.InlineImage
332	for _, att := range atts {
333		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
334			continue
335		}
336		imgs = append(imgs, view.InlineImage{
337			CID:    att.ContentID,
338			Base64: base64.StdEncoding.EncodeToString(att.Data),
339		})
340	}
341	return imgs
342}
343
344func wrapBodyToWidth(body string, width int) string {
345	return BodyStyle.Width(width).Render(body)
346}
347
348// GetEmail returns the email being viewed
349func (m *EmailView) GetEmail() fetcher.Email {
350	return m.email
351}