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, 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 m.attachmentCursor > 0 {
210					m.attachmentCursor--
211				}
212			case "down", kb.Global.NavDown:
213				if m.attachmentCursor < len(m.email.Attachments)-1 {
214					m.attachmentCursor++
215				}
216			case "enter":
217				if len(m.email.Attachments) > 0 {
218					selected := m.email.Attachments[m.attachmentCursor]
219					idx := m.emailIndex
220					accountID := m.accountID
221					return m, func() tea.Msg {
222						return DownloadAttachmentMsg{
223							Index:     idx,
224							Filename:  selected.Filename,
225							PartID:    selected.PartID,
226							Data:      selected.Data,
227							AccountID: accountID,
228							Mailbox:   m.mailbox,
229						}
230					}
231				}
232			case kb.Email.FocusAttachments:
233				m.focusOnAttachments = false
234			}
235		} else {
236			switch msg.String() {
237			case kb.Email.ToggleImages:
238				if view.ImageProtocolSupported() {
239					m.showImages = !m.showImages
240					ClearKittyGraphics()
241
242					inlineImages := inlineImagesFromAttachments(m.email.Attachments)
243					body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
244					if err != nil {
245						body = fmt.Sprintf("Error rendering body: %v", err)
246					}
247					body = applyBodyTransform(body, m.email)
248					m.imagePlacements = placements
249					wrapped := wrapBodyToWidth(body, m.viewport.Width())
250					m.viewport.SetContent(wrapped + "\n")
251					return m, nil
252				}
253			case kb.Email.Reply:
254				// Clear Kitty graphics before opening composer
255				ClearKittyGraphics()
256				return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
257			case kb.Email.Forward:
258				// Clear Kitty graphics before opening composer
259				ClearKittyGraphics()
260				return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
261			case kb.Email.Delete:
262				accountID := m.accountID
263				uid := m.email.UID
264				// Clear Kitty graphics before transitioning
265				ClearKittyGraphics()
266				return m, func() tea.Msg {
267					return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
268				}
269			case kb.Email.Archive:
270				accountID := m.accountID
271				uid := m.email.UID
272				// Clear Kitty graphics before transitioning
273				ClearKittyGraphics()
274				return m, func() tea.Msg {
275					return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
276				}
277			case kb.Email.RsvpAccept, kb.Email.RsvpDecline, kb.Email.RsvpTentative:
278				if m.hasCalendarInvite && m.calendarEvent != nil {
279					var response string
280					switch msg.String() {
281					case kb.Email.RsvpAccept:
282						response = "ACCEPTED"
283					case kb.Email.RsvpDecline:
284						response = "DECLINED"
285					case kb.Email.RsvpTentative:
286						response = "TENTATIVE"
287					}
288
289					return m, func() tea.Msg {
290						return SendRSVPMsg{
291							OriginalICS: m.originalICSData,
292							Event:       m.calendarEvent,
293							Response:    response,
294							AccountID:   m.accountID,
295							InReplyTo:   m.email.MessageID,
296							References:  m.email.References,
297						}
298					}
299				}
300			case kb.Email.FocusAttachments:
301				if len(m.email.Attachments) > 0 {
302					m.focusOnAttachments = true
303				}
304			}
305		}
306	case tea.WindowSizeMsg:
307		header := fmt.Sprintf("To: %s\nFrom: %s\nSubject: %s ", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject)
308		headerHeight := lipgloss.Height(header) + 2
309		attachmentHeight := 0
310		if len(m.email.Attachments) > 0 {
311			attachmentHeight = len(m.email.Attachments) + 2
312		}
313		// Update viewport dimensions
314		m.viewport.SetWidth(msg.Width)
315		m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
316
317		// When the window size changes, wrap and clear kitty images to keep placement stable
318		ClearKittyGraphics()
319		inlineImages := inlineImagesFromAttachments(m.email.Attachments)
320		body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
321		if err != nil {
322			body = fmt.Sprintf("Error rendering body: %v", err)
323		}
324		body = applyBodyTransform(body, m.email)
325		m.imagePlacements = placements
326		wrapped := wrapBodyToWidth(body, m.viewport.Width())
327		m.viewport.SetContent(wrapped + "\n")
328	}
329
330	m.viewport, cmd = m.viewport.Update(msg)
331	cmds = append(cmds, cmd)
332
333	return m, tea.Batch(cmds...)
334}
335
336func (m *EmailView) View() tea.View {
337	// Clear image placements (but keep uploaded image data in terminal memory)
338	// before re-rendering to prevent stacking on scroll. Uses d=a (delete all
339	// placements) instead of d=A (delete all including data) so that images
340	// can be re-displayed by ID without re-uploading.
341	os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
342	os.Stdout.Sync()
343
344	var cryptoStatus strings.Builder
345
346	if m.isEncrypted {
347		cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]"))
348	} else if m.isSMIME {
349		if m.smimeTrusted {
350			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]"))
351		} else {
352			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]"))
353		}
354	}
355	if m.isPGPEncrypted {
356		cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]"))
357	} else if m.isPGP {
358		if m.pgpTrusted {
359			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]"))
360		} else {
361			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]"))
362		}
363	}
364
365	header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus.String())
366	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
367
368	var help string
369	if m.focusOnAttachments {
370		helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
371		if m.pluginStatus != "" {
372			helpText += " • " + m.pluginStatus
373		}
374		help = helpStyle.Render(helpText)
375	} else {
376		var shortcuts strings.Builder
377		shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
378		if view.ImageProtocolSupported() {
379			shortcuts.WriteString("• \uf03e i: toggle images")
380		}
381		for _, pk := range m.pluginKeyBindings {
382			shortcuts.WriteString(" • ")
383			shortcuts.WriteString(pk.Key)
384			shortcuts.WriteString(": ")
385			shortcuts.WriteString(pk.Description)
386		}
387		if m.pluginStatus != "" {
388			shortcuts.WriteString(" • ")
389			shortcuts.WriteString(m.pluginStatus)
390		}
391		help = helpStyle.Render(shortcuts.String())
392	}
393
394	var attachmentView string
395	if len(m.email.Attachments) > 0 {
396		var b strings.Builder
397		b.WriteString("Attachments:\n")
398		for i, attachment := range m.email.Attachments {
399			cursor := "  "
400			style := itemStyle
401			if m.focusOnAttachments && i == m.attachmentCursor {
402				cursor = "> "
403				style = selectedItemStyle
404			}
405			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
406			b.WriteString("\n")
407		}
408		attachmentView = attachmentBoxStyle.Render(b.String())
409	}
410
411	// Render visible images directly to stdout. Bubbletea v2's ultraviolet
412	// renderer uses a cell-based model that cannot pass through graphics
413	// protocol escape sequences, so we write them out-of-band.
414	if m.showImages && len(m.imagePlacements) > 0 {
415		headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
416		yOffset := m.viewport.YOffset()
417		vpHeight := m.viewport.Height()
418
419		for i := range m.imagePlacements {
420			p := &m.imagePlacements[i]
421			// Only render if the image's top line is within the viewport.
422			// We can't partially clip images scrolled off the top (Kitty
423			// always renders from the top-left), so we hide them once
424			// their start line scrolls above the viewport.
425			if p.Line >= yOffset && p.Line < yOffset+vpHeight {
426				screenRow := headerLines + (p.Line - yOffset)
427				if m.columnOffset > 0 {
428					view.RenderImageToStdout(p, screenRow, m.columnOffset+1)
429				} else {
430					view.RenderImageToStdout(p, screenRow)
431				}
432			}
433		}
434	}
435
436	// Render calendar invite card if present
437	var calendarView string
438	if m.hasCalendarInvite && m.calendarEvent != nil {
439		calendarView = renderCalendarInvite(m.calendarEvent)
440	}
441
442	// m.viewport.View() returns a string in Bubbles v2 viewport
443	if calendarView != "" {
444		return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help))
445	}
446	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
447}
448
449// GetAccountID returns the account ID for this email
450func (m *EmailView) GetAccountID() string {
451	return m.accountID
452}
453
454// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
455func (m *EmailView) SetPluginStatus(status string) {
456	m.pluginStatus = status
457}
458
459// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
460func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
461	m.pluginKeyBindings = bindings
462}
463
464func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
465	var imgs []view.InlineImage
466	for _, att := range atts {
467		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
468			continue
469		}
470		imgs = append(imgs, view.InlineImage{
471			CID:    att.ContentID,
472			Base64: base64.StdEncoding.EncodeToString(att.Data),
473		})
474	}
475	return imgs
476}
477
478func wrapBodyToWidth(body string, width int) string {
479	return BodyStyle.Width(width).Render(body)
480}
481
482// GetEmail returns the email being viewed
483func (m *EmailView) GetEmail() fetcher.Email {
484	return m.email
485}
486
487// renderCalendarInvite renders a calendar invite card
488func renderCalendarInvite(event *calendar.Event) string {
489	if event == nil {
490		return ""
491	}
492
493	style := lipgloss.NewStyle().
494		Border(lipgloss.DoubleBorder()).
495		BorderForeground(theme.ActiveTheme.Accent).
496		Padding(1, 2).
497		MarginTop(1).
498		MarginBottom(1)
499
500	var b strings.Builder
501	b.WriteString("📅 Meeting Invite\n\n")
502	b.WriteString(fmt.Sprintf("Title:    %s\n", event.Summary))
503	b.WriteString(fmt.Sprintf("When:     %s\n", formatEventTime(event.Start, event.End)))
504
505	if event.Location != "" {
506		b.WriteString(fmt.Sprintf("Where:    %s\n", event.Location))
507	}
508
509	b.WriteString(fmt.Sprintf("Organizer: %s\n", event.Organizer))
510
511	if event.Description != "" {
512		desc := truncateString(event.Description, 100)
513		b.WriteString(fmt.Sprintf("\n%s\n", desc))
514	}
515
516	b.WriteString("\n")
517	b.WriteString(lipgloss.NewStyle().Italic(true).Render("Press 1:Accept  2:Decline  3:Tentative"))
518
519	return style.Render(b.String())
520}
521
522// formatEventTime formats event start/end times
523func formatEventTime(start, end time.Time) string {
524	start = start.Local()
525	end = end.Local()
526	if start.Format("2006-01-02") == end.Format("2006-01-02") {
527		// Same day
528		return fmt.Sprintf("%s, %s - %s",
529			start.Format("Mon Jan 2, 2006"),
530			start.Format("3:04 PM"),
531			end.Format("3:04 PM"))
532	}
533	// Multi-day
534	return fmt.Sprintf("%s - %s",
535		start.Format("Mon Jan 2 3:04 PM"),
536		end.Format("Mon Jan 2 3:04 PM"))
537}
538
539// truncateString truncates string to maxLen
540func truncateString(s string, maxLen int) string {
541	if len(s) <= maxLen {
542		return s
543	}
544	return s[:maxLen] + "..."
545}