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	var cryptoStatus strings.Builder
327
328	if m.isEncrypted {
329		cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]"))
330	} else if m.isSMIME {
331		if m.smimeTrusted {
332			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]"))
333		} else {
334			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]"))
335		}
336	}
337	if m.isPGPEncrypted {
338		cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]"))
339	} else if m.isPGP {
340		if m.pgpTrusted {
341			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]"))
342		} else {
343			cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]"))
344		}
345	}
346
347	header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus.String())
348	styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
349
350	var help string
351	if m.focusOnAttachments {
352		helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
353		if m.pluginStatus != "" {
354			helpText += " • " + m.pluginStatus
355		}
356		help = helpStyle.Render(helpText)
357	} else {
358		var shortcuts strings.Builder
359		shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
360		if view.ImageProtocolSupported() {
361			shortcuts.WriteString("• \uf03e i: toggle images")
362		}
363		for _, pk := range m.pluginKeyBindings {
364			shortcuts.WriteString(" • ")
365			shortcuts.WriteString(pk.Key)
366			shortcuts.WriteString(": ")
367			shortcuts.WriteString(pk.Description)
368		}
369		if m.pluginStatus != "" {
370			shortcuts.WriteString(" • ")
371			shortcuts.WriteString(m.pluginStatus)
372		}
373		help = helpStyle.Render(shortcuts.String())
374	}
375
376	var attachmentView string
377	if len(m.email.Attachments) > 0 {
378		var b strings.Builder
379		b.WriteString("Attachments:\n")
380		for i, attachment := range m.email.Attachments {
381			cursor := "  "
382			style := itemStyle
383			if m.focusOnAttachments && i == m.attachmentCursor {
384				cursor = "> "
385				style = selectedItemStyle
386			}
387			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
388			b.WriteString("\n")
389		}
390		attachmentView = attachmentBoxStyle.Render(b.String())
391	}
392
393	// Render visible images directly to stdout. Bubbletea v2's ultraviolet
394	// renderer uses a cell-based model that cannot pass through graphics
395	// protocol escape sequences, so we write them out-of-band.
396	if m.showImages && len(m.imagePlacements) > 0 {
397		headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
398		yOffset := m.viewport.YOffset()
399		vpHeight := m.viewport.Height()
400
401		for i := range m.imagePlacements {
402			p := &m.imagePlacements[i]
403			// Only render if the image's top line is within the viewport.
404			// We can't partially clip images scrolled off the top (Kitty
405			// always renders from the top-left), so we hide them once
406			// their start line scrolls above the viewport.
407			if p.Line >= yOffset && p.Line < yOffset+vpHeight {
408				screenRow := headerLines + (p.Line - yOffset)
409				if m.columnOffset > 0 {
410					view.RenderImageToStdout(p, screenRow, m.columnOffset+1)
411				} else {
412					view.RenderImageToStdout(p, screenRow)
413				}
414			}
415		}
416	}
417
418	// Render calendar invite card if present
419	var calendarView string
420	if m.hasCalendarInvite && m.calendarEvent != nil {
421		calendarView = renderCalendarInvite(m.calendarEvent)
422	}
423
424	// m.viewport.View() returns a string in Bubbles v2 viewport
425	if calendarView != "" {
426		return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help))
427	}
428	return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
429}
430
431// GetAccountID returns the account ID for this email
432func (m *EmailView) GetAccountID() string {
433	return m.accountID
434}
435
436// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
437func (m *EmailView) SetPluginStatus(status string) {
438	m.pluginStatus = status
439}
440
441// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
442func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
443	m.pluginKeyBindings = bindings
444}
445
446func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
447	var imgs []view.InlineImage
448	for _, att := range atts {
449		if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
450			continue
451		}
452		imgs = append(imgs, view.InlineImage{
453			CID:    att.ContentID,
454			Base64: base64.StdEncoding.EncodeToString(att.Data),
455		})
456	}
457	return imgs
458}
459
460func wrapBodyToWidth(body string, width int) string {
461	return BodyStyle.Width(width).Render(body)
462}
463
464// GetEmail returns the email being viewed
465func (m *EmailView) GetEmail() fetcher.Email {
466	return m.email
467}
468
469// renderCalendarInvite renders a calendar invite card
470func renderCalendarInvite(event *calendar.Event) string {
471	if event == nil {
472		return ""
473	}
474
475	style := lipgloss.NewStyle().
476		Border(lipgloss.DoubleBorder()).
477		BorderForeground(theme.ActiveTheme.Accent).
478		Padding(1, 2).
479		MarginTop(1).
480		MarginBottom(1)
481
482	var b strings.Builder
483	b.WriteString("📅 Meeting Invite\n\n")
484	b.WriteString(fmt.Sprintf("Title:    %s\n", event.Summary))
485	b.WriteString(fmt.Sprintf("When:     %s\n", formatEventTime(event.Start, event.End)))
486
487	if event.Location != "" {
488		b.WriteString(fmt.Sprintf("Where:    %s\n", event.Location))
489	}
490
491	b.WriteString(fmt.Sprintf("Organizer: %s\n", event.Organizer))
492
493	if event.Description != "" {
494		desc := truncateString(event.Description, 100)
495		b.WriteString(fmt.Sprintf("\n%s\n", desc))
496	}
497
498	b.WriteString("\n")
499	b.WriteString(lipgloss.NewStyle().Italic(true).Render("Press 1:Accept  2:Decline  3:Tentative"))
500
501	return style.Render(b.String())
502}
503
504// formatEventTime formats event start/end times
505func formatEventTime(start, end time.Time) string {
506	start = start.Local()
507	end = end.Local()
508	if start.Format("2006-01-02") == end.Format("2006-01-02") {
509		// Same day
510		return fmt.Sprintf("%s, %s - %s",
511			start.Format("Mon Jan 2, 2006"),
512			start.Format("3:04 PM"),
513			end.Format("3:04 PM"))
514	}
515	// Multi-day
516	return fmt.Sprintf("%s - %s",
517		start.Format("Mon Jan 2 3:04 PM"),
518		end.Format("Mon Jan 2 3:04 PM"))
519}
520
521// truncateString truncates string to maxLen
522func truncateString(s string, maxLen int) string {
523	if len(s) <= maxLen {
524		return s
525	}
526	return s[:maxLen] + "..."
527}