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