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