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}