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