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