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