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