1package tui
2
3import (
4 "encoding/base64"
5 "fmt"
6 "os"
7 "strings"
8
9 "charm.land/bubbles/v2/viewport"
10 tea "charm.land/bubbletea/v2"
11 "charm.land/lipgloss/v2"
12 "github.com/floatpane/matcha/fetcher"
13 "github.com/floatpane/matcha/theme"
14 "github.com/floatpane/matcha/view"
15)
16
17// ClearKittyGraphics sends the Kitty graphics protocol delete command directly to stdout.
18func ClearKittyGraphics() {
19 // Delete all images: a=d (action=delete), d=A (delete all)
20 os.Stdout.WriteString("\x1b_Ga=d,d=A\x1b\\")
21 os.Stdout.Sync()
22}
23
24var (
25 emailHeaderStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1)
26 attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1)
27)
28
29type EmailView struct {
30 viewport viewport.Model
31 email fetcher.Email
32 emailIndex int
33 attachmentCursor int
34 focusOnAttachments bool
35 accountID string
36 mailbox MailboxKind
37 disableImages bool
38 showImages bool
39 isSMIME bool
40 smimeTrusted bool
41 isEncrypted bool
42 isPGP bool
43 pgpTrusted bool
44 isPGPEncrypted bool
45 imagePlacements []view.ImagePlacement
46 pluginStatus string
47 pluginKeyBindings []PluginKeyBinding
48}
49
50func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
51 isSMIME := false
52 smimeTrusted := false
53 isEncrypted := false
54 isPGP := false
55 pgpTrusted := false
56 isPGPEncrypted := false
57 var filteredAtts []fetcher.Attachment
58
59 for _, att := range email.Attachments {
60 if att.Filename == "smime-status.internal" {
61 isSMIME = att.IsSMIMESignature || att.IsSMIMEEncrypted
62 smimeTrusted = att.SMIMEVerified
63 isEncrypted = att.IsSMIMEEncrypted
64 } else if att.IsSMIMESignature || att.Filename == "smime.p7s" || att.Filename == "smime.p7m" || strings.HasPrefix(att.MIMEType, "application/pkcs7") {
65 // Extract S/MIME status from detached signature attachments
66 if att.IsSMIMESignature && !isSMIME {
67 isSMIME = true
68 smimeTrusted = att.SMIMEVerified
69 }
70 // Skip UI rendering
71 } else if att.Filename == "pgp-status.internal" {
72 isPGP = att.IsPGPSignature || att.IsPGPEncrypted
73 pgpTrusted = att.PGPVerified
74 isPGPEncrypted = att.IsPGPEncrypted
75 } else if att.IsPGPSignature || att.Filename == "signature.asc" || att.MIMEType == "application/pgp-signature" || att.MIMEType == "application/pgp-encrypted" {
76 // Extract PGP status from detached signature attachments
77 if att.IsPGPSignature && !isPGP {
78 isPGP = true
79 pgpTrusted = att.PGPVerified
80 }
81 // Skip UI rendering
82 } else {
83 filteredAtts = append(filteredAtts, att)
84 }
85 }
86 email.Attachments = filteredAtts
87
88 // Pass the styles from the tui package to the view package
89 inlineImages := inlineImagesFromAttachments(email.Attachments)
90
91 // Initial state for showImages matches config unless overridden later
92 showImages := !disableImages
93
94 body, placements, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages)
95 if err != nil {
96 body = fmt.Sprintf("Error rendering body: %v", err)
97 }
98
99 // Create header and compute heights that reduce viewport space.
100 header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject)
101 headerHeight := lipgloss.Height(header) + 2
102
103 attachmentHeight := 0
104 if len(email.Attachments) > 0 {
105 attachmentHeight = len(email.Attachments) + 2
106 }
107
108 // Build viewport with initial size and set wrapped content.
109 vp := viewport.New()
110 vp.SetWidth(width)
111 vp.SetHeight(height - headerHeight - attachmentHeight)
112 wrapped := wrapBodyToWidth(body, vp.Width())
113 vp.SetContent(wrapped + "\n")
114
115 return &EmailView{
116 viewport: vp,
117 email: email,
118 emailIndex: emailIndex,
119 accountID: email.AccountID,
120 mailbox: mailbox,
121 disableImages: disableImages,
122 showImages: showImages,
123 isSMIME: isSMIME,
124 smimeTrusted: smimeTrusted,
125 isEncrypted: isEncrypted,
126 isPGP: isPGP,
127 pgpTrusted: pgpTrusted,
128 isPGPEncrypted: isPGPEncrypted,
129 imagePlacements: placements,
130 }
131}
132
133func (m *EmailView) Init() tea.Cmd {
134 return nil
135}
136
137func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138 var cmd tea.Cmd
139 var cmds []tea.Cmd
140
141 switch msg := msg.(type) {
142 case tea.KeyPressMsg:
143 // Handle 'esc' key locally
144 if msg.String() == "esc" {
145 if m.focusOnAttachments {
146 m.focusOnAttachments = false
147 return m, nil
148 }
149 // Clear Kitty graphics before returning to mailbox
150 ClearKittyGraphics()
151 return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} }
152 }
153
154 if m.focusOnAttachments {
155 switch msg.String() {
156 case "up", "k":
157 if m.attachmentCursor > 0 {
158 m.attachmentCursor--
159 }
160 case "down", "j":
161 if m.attachmentCursor < len(m.email.Attachments)-1 {
162 m.attachmentCursor++
163 }
164 case "enter":
165 if len(m.email.Attachments) > 0 {
166 selected := m.email.Attachments[m.attachmentCursor]
167 idx := m.emailIndex
168 accountID := m.accountID
169 return m, func() tea.Msg {
170 return DownloadAttachmentMsg{
171 Index: idx,
172 Filename: selected.Filename,
173 PartID: selected.PartID,
174 Data: selected.Data,
175 AccountID: accountID,
176 Mailbox: m.mailbox,
177 }
178 }
179 }
180 case "tab":
181 m.focusOnAttachments = false
182 }
183 } else {
184 switch msg.String() {
185 case "i":
186 if view.ImageProtocolSupported() {
187 m.showImages = !m.showImages
188 ClearKittyGraphics()
189
190 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
191 body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
192 if err != nil {
193 body = fmt.Sprintf("Error rendering body: %v", err)
194 }
195 m.imagePlacements = placements
196 wrapped := wrapBodyToWidth(body, m.viewport.Width())
197 m.viewport.SetContent(wrapped + "\n")
198 return m, nil
199 }
200 case "r":
201 // Clear Kitty graphics before opening composer
202 ClearKittyGraphics()
203 return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} }
204 case "f":
205 // Clear Kitty graphics before opening composer
206 ClearKittyGraphics()
207 return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} }
208 case "d":
209 accountID := m.accountID
210 uid := m.email.UID
211 // Clear Kitty graphics before transitioning
212 ClearKittyGraphics()
213 return m, func() tea.Msg {
214 return DeleteEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
215 }
216 case "a":
217 accountID := m.accountID
218 uid := m.email.UID
219 // Clear Kitty graphics before transitioning
220 ClearKittyGraphics()
221 return m, func() tea.Msg {
222 return ArchiveEmailMsg{UID: uid, AccountID: accountID, Mailbox: m.mailbox}
223 }
224 case "tab":
225 if len(m.email.Attachments) > 0 {
226 m.focusOnAttachments = true
227 }
228 }
229 }
230 case tea.WindowSizeMsg:
231 header := fmt.Sprintf("To: %s\nFrom: %s\nSubject: %s ", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject)
232 headerHeight := lipgloss.Height(header) + 2
233 attachmentHeight := 0
234 if len(m.email.Attachments) > 0 {
235 attachmentHeight = len(m.email.Attachments) + 2
236 }
237 // Update viewport dimensions
238 m.viewport.SetWidth(msg.Width)
239 m.viewport.SetHeight(msg.Height - headerHeight - attachmentHeight)
240
241 // When the window size changes, wrap and clear kitty images to keep placement stable
242 ClearKittyGraphics()
243 inlineImages := inlineImagesFromAttachments(m.email.Attachments)
244 body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages)
245 if err != nil {
246 body = fmt.Sprintf("Error rendering body: %v", err)
247 }
248 m.imagePlacements = placements
249 wrapped := wrapBodyToWidth(body, m.viewport.Width())
250 m.viewport.SetContent(wrapped + "\n")
251 }
252
253 m.viewport, cmd = m.viewport.Update(msg)
254 cmds = append(cmds, cmd)
255
256 return m, tea.Batch(cmds...)
257}
258
259func (m *EmailView) View() tea.View {
260 // Clear image placements (but keep uploaded image data in terminal memory)
261 // before re-rendering to prevent stacking on scroll. Uses d=a (delete all
262 // placements) instead of d=A (delete all including data) so that images
263 // can be re-displayed by ID without re-uploading.
264 os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\")
265 os.Stdout.Sync()
266
267 cryptoStatus := ""
268 if m.isEncrypted {
269 cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]")
270 } else if m.isSMIME {
271 if m.smimeTrusted {
272 cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]")
273 } else {
274 cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]")
275 }
276 }
277 if m.isPGPEncrypted {
278 cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]")
279 } else if m.isPGP {
280 if m.pgpTrusted {
281 cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]")
282 } else {
283 cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]")
284 }
285 }
286
287 header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus)
288 styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header)
289
290 var help string
291 if m.focusOnAttachments {
292 helpText := "↑/↓: navigate • enter: download • esc/tab: back to email body"
293 if m.pluginStatus != "" {
294 helpText += " • " + m.pluginStatus
295 }
296 help = helpStyle.Render(helpText)
297 } else {
298 shortcuts := "\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox"
299 if view.ImageProtocolSupported() {
300 shortcuts = shortcuts + "• \uf03e i: toggle images"
301 }
302 for _, pk := range m.pluginKeyBindings {
303 shortcuts += " • " + pk.Key + ": " + pk.Description
304 }
305 if m.pluginStatus != "" {
306 shortcuts += " • " + m.pluginStatus
307 }
308 help = helpStyle.Render(shortcuts)
309 }
310
311 var attachmentView string
312 if len(m.email.Attachments) > 0 {
313 var b strings.Builder
314 b.WriteString("Attachments:\n")
315 for i, attachment := range m.email.Attachments {
316 cursor := " "
317 style := itemStyle
318 if m.focusOnAttachments && i == m.attachmentCursor {
319 cursor = "> "
320 style = selectedItemStyle
321 }
322 b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename)))
323 b.WriteString("\n")
324 }
325 attachmentView = attachmentBoxStyle.Render(b.String())
326 }
327
328 // Render visible images directly to stdout. Bubbletea v2's ultraviolet
329 // renderer uses a cell-based model that cannot pass through graphics
330 // protocol escape sequences, so we write them out-of-band.
331 if m.showImages && len(m.imagePlacements) > 0 {
332 headerLines := lipgloss.Height(styledHeader) + 1 // +1 for the newline after header
333 yOffset := m.viewport.YOffset()
334 vpHeight := m.viewport.Height()
335
336 for i := range m.imagePlacements {
337 p := &m.imagePlacements[i]
338 // Only render if the image's top line is within the viewport.
339 // We can't partially clip images scrolled off the top (Kitty
340 // always renders from the top-left), so we hide them once
341 // their start line scrolls above the viewport.
342 if p.Line >= yOffset && p.Line < yOffset+vpHeight {
343 screenRow := headerLines + (p.Line - yOffset)
344 view.RenderImageToStdout(p, screenRow)
345 }
346 }
347 }
348
349 // m.viewport.View() returns a string in Bubbles v2 viewport
350 return tea.NewView(fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help))
351}
352
353// GetAccountID returns the account ID for this email
354func (m *EmailView) GetAccountID() string {
355 return m.accountID
356}
357
358// SetPluginStatus sets a persistent status string from plugins, shown in the help bar.
359func (m *EmailView) SetPluginStatus(status string) {
360 m.pluginStatus = status
361}
362
363// SetPluginKeyBindings sets the plugin-registered key bindings for display in the help bar.
364func (m *EmailView) SetPluginKeyBindings(bindings []PluginKeyBinding) {
365 m.pluginKeyBindings = bindings
366}
367
368func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage {
369 var imgs []view.InlineImage
370 for _, att := range atts {
371 if !att.Inline || len(att.Data) == 0 || att.ContentID == "" {
372 continue
373 }
374 imgs = append(imgs, view.InlineImage{
375 CID: att.ContentID,
376 Base64: base64.StdEncoding.EncodeToString(att.Data),
377 })
378 }
379 return imgs
380}
381
382func wrapBodyToWidth(body string, width int) string {
383 return BodyStyle.Width(width).Render(body)
384}
385
386// GetEmail returns the email being viewed
387func (m *EmailView) GetEmail() fetcher.Email {
388 return m.email
389}