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