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\\") //nolint:errcheck,gosec
24 os.Stdout.Sync() //nolint:errcheck,gosec
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" { //nolint:gocritic
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, email.BodyMIMEType, 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 cmds := make([]tea.Cmd, 0, 1)
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 len(m.email.Attachments) > 0 {
210 m.attachmentCursor = (m.attachmentCursor - 1 + len(m.email.Attachments)) % len(m.email.Attachments)
211 }
212 return m, nil
213 case keyDown, kb.Global.NavDown:
214 if len(m.email.Attachments) > 0 {
215 m.attachmentCursor = (m.attachmentCursor + 1) % len(m.email.Attachments)
216 }
217 return m, nil
218 case keyEnter:
219 if len(m.email.Attachments) > 0 {
220 selected := m.email.Attachments[m.attachmentCursor]
221 idx := m.emailIndex
222 accountID := m.accountID
223 return m, func() tea.Msg {
224 return DownloadAttachmentMsg{
225 Index: idx,
226 Filename: selected.Filename,
227 PartID: selected.PartID,
228 Data: selected.Data,
229 AccountID: accountID,
230 Mailbox: m.mailbox,
231 }
232 }
233 }
234 case kb.Email.FocusAttachments:
235 m.focusOnAttachments = false
236 }
237 } else {
238 switch msg.String() {
239 case kb.Email.ToggleImages:
240 if view.ImageProtocolSupported() {
241 m.showImages = !m.showImages
242 ClearKittyGraphics()
243
244 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
245 body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
246 if err != nil {
247 body = fmt.Sprintf("Error rendering body: %v", err)
248 }
249 body = applyBodyTransform(body, m.email)
250 m.imagePlacements = placements
251 wrapped := wrapBodyToWidth(body, m.viewport.Width())
252 m.viewport.SetContent(wrapped + "\n")
253 return m, nil
254 }
255 case kb.Email.Reply:
256 // Clear Kitty graphics before opening composer
257 ClearKittyGraphics()
258 return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
259 case kb.Email.Forward:
260 // Clear Kitty graphics before opening composer
261 ClearKittyGraphics()
262 return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
263 case kb.Email.Delete:
264 accountID := m.accountID
265 uid := m.email.UID
266 // Clear Kitty graphics before transitioning
267 ClearKittyGraphics()
268 return m, func() tea.Msg {
269 return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
270 }
271 case kb.Email.Archive:
272 accountID := m.accountID
273 uid := m.email.UID
274 // Clear Kitty graphics before transitioning
275 ClearKittyGraphics()
276 return m, func() tea.Msg {
277 return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
278 }
279 case kb.Email.RsvpAccept, kb.Email.RsvpDecline, kb.Email.RsvpTentative:
280 if m.hasCalendarInvite && m.calendarEvent != nil {
281 var response string
282 switch msg.String() {
283 case kb.Email.RsvpAccept:
284 response = "ACCEPTED"
285 case kb.Email.RsvpDecline:
286 response = "DECLINED"
287 case kb.Email.RsvpTentative:
288 response = "TENTATIVE"
289 }
290
291 return m, func() tea.Msg {
292 return SendRSVPMsg{
293 OriginalICS: m.originalICSData,
294 Event: m.calendarEvent,
295 Response: response,
296 AccountID: m.accountID,
297 InReplyTo: m.email.MessageID,
298 References: m.email.References,
299 }
300 }
301 }
302 case kb.Email.FocusAttachments:
303 if len(m.email.Attachments) > 0 {
304 m.focusOnAttachments = true
305 }
306 }
307 }
308 case tea.WindowSizeMsg:
309 header := fmt.Sprintf("To: %s\nFrom: %s\nSubject: %s ", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject)
310 headerHeight := lipgloss.Height(header) + 2
311 attachmentHeight := 0
312 if len(m.email.Attachments) > 0 {
313 attachmentHeight = len(m.email.Attachments) + 2
314 }
315 // Update viewport dimensions
316 m.viewport.SetWidth(msg.Width)
317 m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
318
319 // When the window size changes, wrap and clear kitty images to keep placement stable
320 ClearKittyGraphics()
321 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
322 body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
323 if err != nil {
324 body = fmt.Sprintf("Error rendering body: %v", err)
325 }
326 body = applyBodyTransform(body, m.email)
327 m.imagePlacements = placements
328 wrapped := wrapBodyToWidth(body, m.viewport.Width())
329 m.viewport.SetContent(wrapped + "\n")
330 }
331
332 m.viewport, cmd = m.viewport.Update(msg)
333 cmds = append(cmds, cmd)
334
335 return m, tea.Batch(cmds...)
336}
337
338func (m *EmailView) View() tea.View {
339 // Clear image placements (but keep uploaded image data in terminal memory)
340 // before re-rendering to prevent stacking on scroll. Uses d=a (delete all
341 // placements) instead of d=A (delete all including data) so that images
342 // can be re-displayed by ID without re-uploading.
343 os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\") //nolint:errcheck,gosec
344 os.Stdout.Sync() //nolint:errcheck,gosec
345
346 var cryptoStatus strings.Builder
347
348 if m.isEncrypted {
349 cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]"))
350 } else if m.isSMIME {
351 if m.smimeTrusted {
352 cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]"))
353 } else {
354 cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]"))
355 }
356 }
357 if m.isPGPEncrypted {
358 cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]"))
359 } else if m.isPGP {
360 if m.pgpTrusted {
361 cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]"))
362 } else {
363 cryptoStatus.WriteString(lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]"))
364 }
365 }
366
367 header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus.String())
368 styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
369
370 var help string
371 if m.focusOnAttachments {
372 helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
373 if m.pluginStatus != "" {
374 helpText += " • " + m.pluginStatus
375 }
376 help = helpStyle.Render(helpText)
377 } else {
378 var shortcuts strings.Builder
379 shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox")
380 if view.ImageProtocolSupported() {
381 shortcuts.WriteString("• \uf03e i: toggle images")
382 }
383 for _, pk := range m.pluginKeyBindings {
384 shortcuts.WriteString(" • ")
385 shortcuts.WriteString(pk.Key)
386 shortcuts.WriteString(": ")
387 shortcuts.WriteString(pk.Description)
388 }
389 if m.pluginStatus != "" {
390 shortcuts.WriteString(" • ")
391 shortcuts.WriteString(m.pluginStatus)
392 }
393 help = helpStyle.Render(shortcuts.String())
394 }
395
396 var attachmentView string
397 if len(m.email.Attachments) > 0 {
398 var b strings.Builder
399 b.WriteString("Attachments:\n")
400 for i, attachment := range m.email.Attachments {
401 cursor := " "
402 style := itemStyle
403 if m.focusOnAttachments && i == m.attachmentCursor {
404 cursor = "> "
405 style = selectedItemStyle
406 }
407 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
408 b.WriteString("\n")
409 }
410 attachmentView = attachmentBoxStyle.Render(b.String())
411 }
412
413 // Render visible images directly to stdout. Bubbletea v2's ultraviolet
414 // renderer uses a cell-based model that cannot pass through graphics
415 // protocol escape sequences, so we write them out-of-band.
416 if m.showImages && len(m.imagePlacements) > 0 {
417 headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
418 yOffset := m.viewport.YOffset()
419 vpHeight := m.viewport.Height()
420
421 for i := range m.imagePlacements {
422 p := &m.imagePlacements[i]
423 // Only render if the image's top line is within the viewport.
424 // We can't partially clip images scrolled off the top (Kitty
425 // always renders from the top-left), so we hide them once
426 // their start line scrolls above the viewport.
427 if p.Line >= yOffset && p.Line < yOffset+vpHeight {
428 screenRow := headerLines + (p.Line - yOffset)
429 if m.columnOffset > 0 {
430 view.RenderImageToStdout(p, screenRow, m.columnOffset+1)
431 } else {
432 view.RenderImageToStdout(p, screenRow)
433 }
434 }
435 }
436 }
437
438 // Render calendar invite card if present
439 var calendarView string
440 if m.hasCalendarInvite && m.calendarEvent != nil {
441 calendarView = renderCalendarInvite(m.calendarEvent)
442 }
443
444 // m.viewport.View() returns a string in Bubbles v2 viewport
445 if calendarView != "" {
446 return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help))
447 }
448 return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
449}
450
451// GetAccountID returns the account ID for this email
452func (m *EmailView) GetAccountID() string {
453 return m.accountID
454}
455
456// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
457func (m *EmailView) SetPluginStatus(status string) {
458 m.pluginStatus = status
459}
460
461// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
462func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
463 m.pluginKeyBindings = bindings
464}
465
466func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
467 var imgs []view.InlineImage
468 for _, att := range atts {
469 if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
470 continue
471 }
472 imgs = append(imgs, view.InlineImage{
473 CID: att.ContentID,
474 Base64: base64.StdEncoding.EncodeToString(att.Data),
475 })
476 }
477 return imgs
478}
479
480func wrapBodyToWidth(body string, width int) string {
481 return BodyStyle.Width(width).Render(body)
482}
483
484// GetEmail returns the email being viewed
485func (m *EmailView) GetEmail() fetcher.Email {
486 return m.email
487}
488
489// renderCalendarInvite renders a calendar invite card
490func renderCalendarInvite(event *calendar.Event) string {
491 if event == nil {
492 return ""
493 }
494
495 style := lipgloss.NewStyle().
496 Border(lipgloss.DoubleBorder()).
497 BorderForeground(theme.ActiveTheme.Accent).
498 Padding(1, 2).
499 MarginTop(1).
500 MarginBottom(1)
501
502 var b strings.Builder
503 b.WriteString("📅 Meeting Invite\n\n")
504 fmt.Fprintf(&b, "Title: %s\n", event.Summary)
505 fmt.Fprintf(&b, "When: %s\n", formatEventTime(event.Start, event.End))
506
507 if event.Location != "" {
508 fmt.Fprintf(&b, "Where: %s\n", event.Location)
509 }
510
511 fmt.Fprintf(&b, "Organizer: %s\n", event.Organizer)
512
513 if event.Description != "" {
514 desc := truncateString(event.Description, 100)
515 fmt.Fprintf(&b, "\n%s\n", desc)
516 }
517
518 b.WriteString("\n")
519 b.WriteString(lipgloss.NewStyle().Italic(true).Render("Press 1:Accept 2:Decline 3:Tentative"))
520
521 return style.Render(b.String())
522}
523
524// formatEventTime formats event start/end times
525func formatEventTime(start, end time.Time) string {
526 start = start.Local()
527 end = end.Local()
528 if start.Format("2006-01-02") == end.Format("2006-01-02") {
529 // Same day
530 return fmt.Sprintf("%s, %s - %s",
531 start.Format("Mon Jan 2, 2006"),
532 start.Format("3:04 PM"),
533 end.Format("3:04 PM"))
534 }
535 // Multi-day
536 return fmt.Sprintf("%s - %s",
537 start.Format("Mon Jan 2 3:04 PM"),
538 end.Format("Mon Jan 2 3:04 PM"))
539}
540
541// truncateString truncates string to maxLen
542func truncateString(s string, maxLen int) string {
543 if len(s) <= maxLen {
544 return s
545 }
546 return s[:maxLen] + "..."
547}