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