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