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