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