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