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