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