email_view.go

  1package tui
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"os"
  7	"strings"
  8	"time"
  9
 10	"charm.land/bubbles/v2/viewport"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/floatpane/matcha/calendar"
 14	"github.com/floatpane/matcha/fetcher"
 15	"github.com/floatpane/matcha/theme"
 16	"github.com/floatpane/matcha/view"
 17)
 18
 19// ClearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout.
 20func ClearKittyGraphics() {
 21	// Delete all images: a=d (action=delete), d=A (delete all)
 22	os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
 23	os.Stdout.Sync()
 24}
 25
 26var (
 27	emailHeaderStyle   = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
 28	attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
 29)
 30
 31type EmailView struct {
 32	viewport           viewport.Model
 33	email              fetcher.Email
 34	emailIndex         int
 35	attachmentCursor   int
 36	focusOnAttachments bool
 37	accountID          string
 38	mailbox            MailboxKind
 39	disableImages      bool
 40	showImages         bool
 41	isSMIME            bool
 42	smimeTrusted       bool
 43	isEncrypted        bool
 44	isPGP              bool
 45	pgpTrusted         bool
 46	isPGPEncrypted     bool
 47	imagePlacements    []view.ImagePlacement
 48	pluginStatus       string
 49	pluginKeyBindings  []PluginKeyBinding
 50	hasCalendarInvite  bool
 51	calendarEvent      *calendar.Event
 52	originalICSData    []byte
 53}
 54
 55func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
 56	isSMIME := false
 57	smimeTrusted := false
 58	isEncrypted := false
 59	isPGP := false
 60	pgpTrusted := false
 61	isPGPEncrypted := false
 62	var filteredAtts []fetcher.Attachment
 63	var calendarEvent *calendar.Event
 64	var originalICSData []byte
 65
 66	for _, att := range email.Attachments {
 67		if att.Filename == "smime-status.internal" {
 68			isSMIME = att.IsSMIMESignature || att.IsSMIMEEncrypted
 69			smimeTrusted = att.SMIMEVerified
 70			isEncrypted = att.IsSMIMEEncrypted
 71		} else if att.IsSMIMESignature || att.Filename == "smime.p7s" || att.Filename == "smime.p7m" || strings.HasPrefix(att.MIMEType, "application/pkcs7") {
 72			// Extract S/MIME status from detached signature attachments
 73			if att.IsSMIMESignature && !isSMIME {
 74				isSMIME = true
 75				smimeTrusted = att.SMIMEVerified
 76			}
 77			// Skip UI rendering
 78		} else if att.Filename == "pgp-status.internal" {
 79			isPGP = att.IsPGPSignature || att.IsPGPEncrypted
 80			pgpTrusted = att.PGPVerified
 81			isPGPEncrypted = att.IsPGPEncrypted
 82		} else if att.IsPGPSignature || att.Filename == "signature.asc" || att.MIMEType == "application/pgp-signature" || att.MIMEType == "application/pgp-encrypted" {
 83			// Extract PGP status from detached signature attachments
 84			if att.IsPGPSignature && !isPGP {
 85				isPGP = true
 86				pgpTrusted = att.PGPVerified
 87			}
 88			// Skip UI rendering
 89		} else if att.IsCalendarInvite {
 90			// Parse calendar invite if not already parsed
 91			if len(att.Data) > 0 && calendarEvent == nil {
 92				if event, err := calendar.ParseICS(att.Data); err == nil {
 93					calendarEvent = event
 94					originalICSData = att.Data
 95				}
 96			}
 97			// Don't show .ics in regular attachment list
 98		} else {
 99			filteredAtts = append(filteredAtts, att)
100		}
101	}
102	email.Attachments = filteredAtts
103
104	// Pass the styles from the tui package to the view package
105	inlineImages := inlineImagesFromAttachments(email.Attachments)
106
107	// Initial state for showImages matches config unless overridden later
108	showImages := !disableImages
109
110	body, placements, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
111	if err != nil {
112		body = fmt.Sprintf("Error rendering body: %v", err)
113	}
114
115	// Create header and compute heights that reduce viewport space.
116	header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
117	headerHeight := lipgloss.Height(header) + 2
118
119	attachmentHeight := 0
120	if len(email.Attachments) > 0 {
121		attachmentHeight = len(email.Attachments) + 2
122	}
123
124	// Account for calendar card height
125	calendarHeight := 0
126	if calendarEvent != nil {
127		calendarHeight = 10 // Approximate height for calendar card
128	}
129
130	// Build viewport with initial size and set wrapped content.
131	vp := viewport.New()
132	vp.SetWidth(width)
133	vp.SetHeight(height - headerHeight - attachmentHeight - calendarHeight)
134	wrapped := wrapBodyToWidth(body, vp.Width())
135	vp.SetContent(wrapped + "\n")
136
137	return &EmailView{
138		viewport:          vp,
139		email:             email,
140		emailIndex:        emailIndex,
141		accountID:         email.AccountID,
142		mailbox:           mailbox,
143		disableImages:     disableImages,
144		showImages:        showImages,
145		isSMIME:           isSMIME,
146		smimeTrusted:      smimeTrusted,
147		isEncrypted:       isEncrypted,
148		isPGP:             isPGP,
149		pgpTrusted:        pgpTrusted,
150		isPGPEncrypted:    isPGPEncrypted,
151		imagePlacements:   placements,
152		hasCalendarInvite: calendarEvent != nil,
153		calendarEvent:     calendarEvent,
154		originalICSData:   originalICSData,
155	}
156}
157
158func (m *EmailView) Init() tea.Cmd {
159	return nil
160}
161
162func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
163	var cmd tea.Cmd
164	var cmds []tea.Cmd
165
166	switch msg := msg.(type) {
167	case tea.KeyPressMsg:
168		// Handle 'esc' key locally
169		if msg.String() == "esc" {
170			if m.focusOnAttachments {
171				m.focusOnAttachments = false
172				return m, nil
173			}
174			// Clear Kitty graphics before returning to mailbox
175			ClearKittyGraphics()
176			return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
177		}
178
179		if m.focusOnAttachments {
180			switch msg.String() {
181			case "up", "k":
182				if m.attachmentCursor > 0 {
183					m.attachmentCursor--
184				}
185			case "down", "j":
186				if m.attachmentCursor < len(m.email.Attachments)-1 {
187					m.attachmentCursor++
188				}
189			case "enter":
190				if len(m.email.Attachments) > 0 {
191					selected := m.email.Attachments[m.attachmentCursor]
192					idx := m.emailIndex
193					accountID := m.accountID
194					return m, func() tea.Msg {
195						return DownloadAttachmentMsg{
196							Index:     idx,
197							Filename:  selected.Filename,
198							PartID:    selected.PartID,
199							Data:      selected.Data,
200							AccountID: accountID,
201							Mailbox:   m.mailbox,
202						}
203					}
204				}
205			case "tab":
206				m.focusOnAttachments = false
207			}
208		} else {
209			switch msg.String() {
210			case "i":
211				if view.ImageProtocolSupported() {
212					m.showImages = !m.showImages
213					ClearKittyGraphics()
214
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					return m, nil
224				}
225			case "r":
226				// Clear Kitty graphics before opening composer
227				ClearKittyGraphics()
228				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
229			case "f":
230				// Clear Kitty graphics before opening composer
231				ClearKittyGraphics()
232				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
233			case "d":
234				accountID := m.accountID
235				uid := m.email.UID
236				// Clear Kitty graphics before transitioning
237				ClearKittyGraphics()
238				return m, func() tea.Msg {
239					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
240				}
241			case "a":
242				accountID := m.accountID
243				uid := m.email.UID
244				// Clear Kitty graphics before transitioning
245				ClearKittyGraphics()
246				return m, func() tea.Msg {
247					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
248				}
249			case "1", "2", "3":
250				if m.hasCalendarInvite && m.calendarEvent != nil {
251					var response string
252					switch msg.String() {
253					case "1":
254						response = "ACCEPTED"
255					case "2":
256						response = "DECLINED"
257					case "3":
258						response = "TENTATIVE"
259					}
260
261					return m, func() tea.Msg {
262						return SendRSVPMsg{
263							OriginalICS: m.originalICSData,
264							Event:       m.calendarEvent,
265							Response:    response,
266							AccountID:   m.accountID,
267							InReplyTo:   m.email.MessageID,
268							References:  m.email.References,
269						}
270					}
271				}
272			case "tab":
273				if len(m.email.Attachments) > 0 {
274					m.focusOnAttachments = true
275				}
276			}
277		}
278	case tea.WindowSizeMsg:
279		header := fmt.Sprintf("To: %s\nFrom: %s\nSubject: %s ", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject)
280		headerHeight := lipgloss.Height(header) + 2
281		attachmentHeight := 0
282		if len(m.email.Attachments) > 0 {
283			attachmentHeight = len(m.email.Attachments) + 2
284		}
285		// Update viewport dimensions
286		m.viewport.SetWidth(msg.Width)
287		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
288
289		// When the window size changes, wrap and clear kitty images to keep placement stable
290		ClearKittyGraphics()
291		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
292		body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
293		if err != nil {
294			body = fmt.Sprintf("Error rendering body: %v", err)
295		}
296		m.imagePlacements = placements
297		wrapped := wrapBodyToWidth(body, m.viewport.Width())
298		m.viewport.SetContent(wrapped + "\n")
299	}
300
301	m.viewport, cmd = m.viewport.Update(msg)
302	cmds = append(cmds, cmd)
303
304	return m, tea.Batch(cmds...)
305}
306
307func (m *EmailView) View() tea.View {
308	// Clear image placements (but keep uploaded image data in terminal memory)
309	// before re-rendering to prevent stacking on scroll. Uses d=a (delete all
310	// placements) instead of d=A (delete all including data) so that images
311	// can be re-displayed by ID without re-uploading.
312	os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
313	os.Stdout.Sync()
314
315	cryptoStatus := ""
316	if m.isEncrypted {
317		cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
318	} else if m.isSMIME {
319		if m.smimeTrusted {
320			cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
321		} else {
322			cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
323		}
324	}
325	if m.isPGPEncrypted {
326		cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]")
327	} else if m.isPGP {
328		if m.pgpTrusted {
329			cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]")
330		} else {
331			cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]")
332		}
333	}
334
335	header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus)
336	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
337
338	var help string
339	if m.focusOnAttachments {
340		helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
341		if m.pluginStatus != "" {
342			helpText += " • " + m.pluginStatus
343		}
344		help = helpStyle.Render(helpText)
345	} else {
346		shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
347		if view.ImageProtocolSupported() {
348			shortcuts = shortcuts + "• \uf03e i: toggle images"
349		}
350		for _, pk := range m.pluginKeyBindings {
351			shortcuts += " • " + pk.Key + ": " + pk.Description
352		}
353		if m.pluginStatus != "" {
354			shortcuts += " • " + m.pluginStatus
355		}
356		help = helpStyle.Render(shortcuts)
357	}
358
359	var attachmentView string
360	if len(m.email.Attachments) > 0 {
361		var b strings.Builder
362		b.WriteString("Attachments:\n")
363		for i, attachment := range m.email.Attachments {
364			cursor := "  "
365			style := itemStyle
366			if m.focusOnAttachments && i == m.attachmentCursor {
367				cursor = "> "
368				style = selectedItemStyle
369			}
370			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
371			b.WriteString("\n")
372		}
373		attachmentView = attachmentBoxStyle.Render(b.String())
374	}
375
376	// Render visible images directly to stdout. Bubbletea v2's ultraviolet
377	// renderer uses a cell-based model that cannot pass through graphics
378	// protocol escape sequences, so we write them out-of-band.
379	if m.showImages && len(m.imagePlacements) > 0 {
380		headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
381		yOffset := m.viewport.YOffset()
382		vpHeight := m.viewport.Height()
383
384		for i := range m.imagePlacements {
385			p := &m.imagePlacements[i]
386			// Only render if the image's top line is within the viewport.
387			// We can't partially clip images scrolled off the top (Kitty
388			// always renders from the top-left), so we hide them once
389			// their start line scrolls above the viewport.
390			if p.Line >= yOffset && p.Line < yOffset+vpHeight {
391				screenRow := headerLines + (p.Line - yOffset)
392				view.RenderImageToStdout(p, screenRow)
393			}
394		}
395	}
396
397	// Render calendar invite card if present
398	var calendarView string
399	if m.hasCalendarInvite && m.calendarEvent != nil {
400		calendarView = renderCalendarInvite(m.calendarEvent)
401	}
402
403	// m.viewport.View() returns a string in Bubbles v2 viewport
404	if calendarView != "" {
405		return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help))
406	}
407	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
408}
409
410// GetAccountID returns the account ID for this email
411func (m *EmailView) GetAccountID() string {
412	return m.accountID
413}
414
415// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
416func (m *EmailView) SetPluginStatus(status string) {
417	m.pluginStatus = status
418}
419
420// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
421func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
422	m.pluginKeyBindings = bindings
423}
424
425func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
426	var imgs []view.InlineImage
427	for _, att := range atts {
428		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
429			continue
430		}
431		imgs = append(imgs, view.InlineImage{
432			CID:    att.ContentID,
433			Base64: base64.StdEncoding.EncodeToString(att.Data),
434		})
435	}
436	return imgs
437}
438
439func wrapBodyToWidth(body string, width int) string {
440	return BodyStyle.Width(width).Render(body)
441}
442
443// GetEmail returns the email being viewed
444func (m *EmailView) GetEmail() fetcher.Email {
445	return m.email
446}
447
448// renderCalendarInvite renders a calendar invite card
449func renderCalendarInvite(event *calendar.Event) string {
450	style := lipgloss.NewStyle().
451		Border(lipgloss.DoubleBorder()).
452		BorderForeground(theme.ActiveTheme.Accent).
453		Padding(1, 2).
454		MarginTop(1).
455		MarginBottom(1)
456
457	var b strings.Builder
458	b.WriteString("📅 Meeting Invite\n\n")
459	b.WriteString(fmt.Sprintf("Title:    %s\n", event.Summary))
460	b.WriteString(fmt.Sprintf("When:     %s\n", formatEventTime(event.Start, event.End)))
461
462	if event.Location != "" {
463		b.WriteString(fmt.Sprintf("Where:    %s\n", event.Location))
464	}
465
466	b.WriteString(fmt.Sprintf("Organizer: %s\n", event.Organizer))
467
468	if event.Description != "" {
469		desc := truncateString(event.Description, 100)
470		b.WriteString(fmt.Sprintf("\n%s\n", desc))
471	}
472
473	b.WriteString("\n")
474	b.WriteString(lipgloss.NewStyle().Italic(true).Render("Press 1:Accept  2:Decline  3:Tentative"))
475
476	return style.Render(b.String())
477}
478
479// formatEventTime formats event start/end times
480func formatEventTime(start, end time.Time) string {
481	if start.Format("2006-01-02") == end.Format("2006-01-02") {
482		// Same day
483		return fmt.Sprintf("%s, %s - %s",
484			start.Format("Mon Jan 2, 2006"),
485			start.Format("3:04 PM"),
486			end.Format("3:04 PM"))
487	}
488	// Multi-day
489	return fmt.Sprintf("%s - %s",
490		start.Format("Mon Jan 2 3:04 PM"),
491		end.Format("Mon Jan 2 3:04 PM"))
492}
493
494// truncateString truncates string to maxLen
495func truncateString(s string, maxLen int) string {
496	if len(s) <= maxLen {
497		return s
498	}
499	return s[:maxLen] + "..."
500}