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