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 cryptoStatus := ""
327 if m.isEncrypted {
328 cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
329 } else if m.isSMIME {
330 if m.smimeTrusted {
331 cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
332 } else {
333 cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
334 }
335 }
336 if m.isPGPEncrypted {
337 cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]")
338 } else if m.isPGP {
339 if m.pgpTrusted {
340 cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]")
341 } else {
342 cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]")
343 }
344 }
345
346 header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus)
347 styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
348
349 var help string
350 if m.focusOnAttachments {
351 helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
352 if m.pluginStatus != "" {
353 helpText += " • " + m.pluginStatus
354 }
355 help = helpStyle.Render(helpText)
356 } else {
357 shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
358 if view.ImageProtocolSupported() {
359 shortcuts = shortcuts + "• \uf03e i: toggle images"
360 }
361 for _, pk := range m.pluginKeyBindings {
362 shortcuts += " • " + pk.Key + ": " + pk.Description
363 }
364 if m.pluginStatus != "" {
365 shortcuts += " • " + m.pluginStatus
366 }
367 help = helpStyle.Render(shortcuts)
368 }
369
370 var attachmentView string
371 if len(m.email.Attachments) > 0 {
372 var b strings.Builder
373 b.WriteString("Attachments:\n")
374 for i, attachment := range m.email.Attachments {
375 cursor := " "
376 style := itemStyle
377 if m.focusOnAttachments && i == m.attachmentCursor {
378 cursor = "> "
379 style = selectedItemStyle
380 }
381 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
382 b.WriteString("\n")
383 }
384 attachmentView = attachmentBoxStyle.Render(b.String())
385 }
386
387 // Render visible images directly to stdout. Bubbletea v2's ultraviolet
388 // renderer uses a cell-based model that cannot pass through graphics
389 // protocol escape sequences, so we write them out-of-band.
390 if m.showImages && len(m.imagePlacements) > 0 {
391 headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
392 yOffset := m.viewport.YOffset()
393 vpHeight := m.viewport.Height()
394
395 for i := range m.imagePlacements {
396 p := &m.imagePlacements[i]
397 // Only render if the image's top line is within the viewport.
398 // We can't partially clip images scrolled off the top (Kitty
399 // always renders from the top-left), so we hide them once
400 // their start line scrolls above the viewport.
401 if p.Line >= yOffset && p.Line < yOffset+vpHeight {
402 screenRow := headerLines + (p.Line - yOffset)
403 if m.columnOffset > 0 {
404 view.RenderImageToStdout(p, screenRow, m.columnOffset+1)
405 } else {
406 view.RenderImageToStdout(p, screenRow)
407 }
408 }
409 }
410 }
411
412 // Render calendar invite card if present
413 var calendarView string
414 if m.hasCalendarInvite && m.calendarEvent != nil {
415 calendarView = renderCalendarInvite(m.calendarEvent)
416 }
417
418 // m.viewport.View() returns a string in Bubbles v2 viewport
419 if calendarView != "" {
420 return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s\n%s", styledHeader, calendarView, m.viewport.View(), attachmentView, help))
421 }
422 return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
423}
424
425// GetAccountID returns the account ID for this email
426func (m *EmailView) GetAccountID() string {
427 return m.accountID
428}
429
430// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
431func (m *EmailView) SetPluginStatus(status string) {
432 m.pluginStatus = status
433}
434
435// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
436func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
437 m.pluginKeyBindings = bindings
438}
439
440func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
441 var imgs []view.InlineImage
442 for _, att := range atts {
443 if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
444 continue
445 }
446 imgs = append(imgs, view.InlineImage{
447 CID: att.ContentID,
448 Base64: base64.StdEncoding.EncodeToString(att.Data),
449 })
450 }
451 return imgs
452}
453
454func wrapBodyToWidth(body string, width int) string {
455 return BodyStyle.Width(width).Render(body)
456}
457
458// GetEmail returns the email being viewed
459func (m *EmailView) GetEmail() fetcher.Email {
460 return m.email
461}
462
463// renderCalendarInvite renders a calendar invite card
464func renderCalendarInvite(event *calendar.Event) string {
465 style := lipgloss.NewStyle().
466 Border(lipgloss.DoubleBorder()).
467 BorderForeground(theme.ActiveTheme.Accent).
468 Padding(1, 2).
469 MarginTop(1).
470 MarginBottom(1)
471
472 var b strings.Builder
473 b.WriteString("📅 Meeting Invite\n\n")
474 b.WriteString(fmt.Sprintf("Title: %s\n", event.Summary))
475 b.WriteString(fmt.Sprintf("When: %s\n", formatEventTime(event.Start, event.End)))
476
477 if event.Location != "" {
478 b.WriteString(fmt.Sprintf("Where: %s\n", event.Location))
479 }
480
481 b.WriteString(fmt.Sprintf("Organizer: %s\n", event.Organizer))
482
483 if event.Description != "" {
484 desc := truncateString(event.Description, 100)
485 b.WriteString(fmt.Sprintf("\n%s\n", desc))
486 }
487
488 b.WriteString("\n")
489 b.WriteString(lipgloss.NewStyle().Italic(true).Render("Press 1:Accept 2:Decline 3:Tentative"))
490
491 return style.Render(b.String())
492}
493
494// formatEventTime formats event start/end times
495func formatEventTime(start, end time.Time) string {
496 start = start.Local()
497 end = end.Local()
498 if start.Format("2006-01-02") == end.Format("2006-01-02") {
499 // Same day
500 return fmt.Sprintf("%s, %s - %s",
501 start.Format("Mon Jan 2, 2006"),
502 start.Format("3:04 PM"),
503 end.Format("3:04 PM"))
504 }
505 // Multi-day
506 return fmt.Sprintf("%s - %s",
507 start.Format("Mon Jan 2 3:04 PM"),
508 end.Format("Mon Jan 2 3:04 PM"))
509}
510
511// truncateString truncates string to maxLen
512func truncateString(s string, maxLen int) string {
513 if len(s) <= maxLen {
514 return s
515 }
516 return s[:maxLen] + "..."
517}