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