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