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}