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