diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 3f45d9d143c1c4ea59174c14092f1019ffc6c5a3..89cec4cba3e6ff012a1dd4b576cf61c5928662c2 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "mime" "mime/quotedprintable" + "os" "strings" "time" @@ -21,10 +22,13 @@ import ( // Attachment holds data for an email attachment. type Attachment struct { - Filename string - PartID string // Keep PartID to fetch on demand - Data []byte - Encoding string // Store encoding for proper decoding + Filename string + PartID string // Keep PartID to fetch on demand + Data []byte + Encoding string // Store encoding for proper decoding + MIMEType string // Full MIME type (e.g., image/png) + ContentID string // Content-ID for inline assets (e.g., cid: references) + Inline bool // True when the part is meant to be displayed inline } type Email struct { @@ -86,6 +90,18 @@ func decodeHeader(header string) string { return decoded } +func decodeAttachmentData(rawBytes []byte, encoding string) ([]byte, error) { + switch strings.ToLower(encoding) { + case "base64": + decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes)) + return ioutil.ReadAll(decoder) + case "quoted-printable": + return ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes))) + default: + return rawBytes, nil + } +} + func connect(account *config.Account) (*client.Client, error) { imapServer := account.GetIMAPServer() imapPort := account.GetIMAPPort() @@ -234,6 +250,41 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint seqset := new(imap.SeqSet) seqset.AddNum(uid) + fetchInlinePart := func(partID, encoding string) ([]byte, error) { + fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID)) + section, err := imap.ParseBodySectionName(fetchItem) + if err != nil { + return nil, err + } + + partMessages := make(chan *imap.Message, 1) + partDone := make(chan error, 1) + go func() { + partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) + }() + + if err := <-partDone; err != nil { + return nil, err + } + + partMsg := <-partMessages + if partMsg == nil { + return nil, fmt.Errorf("could not fetch inline part %s", partID) + } + + literal := partMsg.GetBody(section) + if literal == nil { + return nil, fmt.Errorf("could not get inline part body %s", partID) + } + + rawBytes, err := ioutil.ReadAll(literal) + if err != nil { + return nil, err + } + + return decodeAttachmentData(rawBytes, encoding) + } + messages := make(chan *imap.Message, 1) done := make(chan error, 1) fetchItems := []imap.FetchItem{imap.FetchBodyStructure} @@ -250,13 +301,24 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid) } - var textPartID string + var plainPartID string + var htmlPartID string var attachments []Attachment var checkPart func(part *imap.BodyStructure, partID string) checkPart = func(part *imap.BodyStructure, partID string) { - // Check for text content - if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" { - textPartID = partID + // Check for text content (prefer html over plain) + if part.MIMEType == "text" { + sub := strings.ToLower(part.MIMESubType) + switch sub { + case "html": + if htmlPartID == "" { + htmlPartID = partID + } + case "plain": + if plainPartID == "" { + plainPartID = partID + } + } } // Check for attachments using multiple methods @@ -284,13 +346,31 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } - // Add as attachment if it has a disposition or a filename (and not just plain text) - if filename != "" && (part.Disposition == "attachment" || part.Disposition == "inline" || part.MIMEType != "text") { - attachments = append(attachments, Attachment{ - Filename: filename, - PartID: partID, - Encoding: part.Encoding, // Store encoding for proper decoding - }) + // Add as attachment if it has a disposition or a filename (and not just plain text). + // Allow inline parts without filenames (common for cid images). + contentID := strings.Trim(part.Id, "<>") + mimeType := fmt.Sprintf("%s/%s", strings.ToLower(part.MIMEType), strings.ToLower(part.MIMESubType)) + isCID := contentID != "" + isInline := part.Disposition == "inline" || isCID + + if filename == "" && isInline && strings.HasPrefix(mimeType, "image/") { + filename = "inline" + } + if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") { + att := Attachment{ + Filename: filename, + PartID: partID, + Encoding: part.Encoding, // Store encoding for proper decoding + MIMEType: mimeType, + ContentID: contentID, + Inline: isInline, + } + if att.Inline && strings.HasPrefix(att.MIMEType, "image/") { + if data, err := fetchInlinePart(partID, part.Encoding); err == nil { + att.Data = data + } + } + attachments = append(attachments, att) } } @@ -323,6 +403,22 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint findParts(msg.BodyStructure, "") var body string + textPartID := "" + if htmlPartID != "" { + textPartID = htmlPartID + } else if plainPartID != "" { + textPartID = plainPartID + } + if os.Getenv("DEBUG_KITTY_IMAGES") != "" { + msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID) + fmt.Print(msg) + if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" { + if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { + _, _ = f.WriteString(msg) + _ = f.Close() + } + } + } if textPartID != "" { partMessages := make(chan *imap.Message, 1) partDone := make(chan error, 1) @@ -430,23 +526,11 @@ func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uin return nil, err } - switch strings.ToLower(encoding) { - case "base64": - decoder := base64.NewDecoder(base64.StdEncoding, bytes.NewReader(rawBytes)) - decoded, err := ioutil.ReadAll(decoder) - if err == nil { - return decoded, nil - } - return rawBytes, nil - case "quoted-printable": - decoded, err := ioutil.ReadAll(quotedprintable.NewReader(bytes.NewReader(rawBytes))) - if err == nil { - return decoded, nil - } - return rawBytes, nil - default: + decoded, err := decodeAttachmentData(rawBytes, encoding) + if err != nil { return rawBytes, nil } + return decoded, nil } func moveEmail(account *config.Account, uid uint32, sourceMailbox, destMailbox string) error { diff --git a/go.mod b/go.mod index 17202eef7b46405bdc1c38f869c94721c2b483e7..0112a80858112cfaa5b3f67153617696860cfcb5 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/emersion/go-message v0.18.2 github.com/google/uuid v1.6.0 github.com/yuin/goldmark v1.7.13 + golang.org/x/sys v0.38.0 golang.org/x/text v0.32.0 ) @@ -35,5 +36,4 @@ require ( github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/sys v0.38.0 // indirect ) diff --git a/tui/email_view.go b/tui/email_view.go index e7449d1d4e6c9bd273933e54d39defeb68afc866..6ba73fcd03beb2289c0fc32903f929718f045c6b 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/base64" "fmt" "strings" @@ -28,7 +29,8 @@ type EmailView struct { func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind) *EmailView { // Pass the styles from the tui package to the view package - body, err := view.ProcessBody(email.Body, H1Style, H2Style, BodyStyle) + inlineImages := inlineImagesFromAttachments(email.Attachments) + body, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } @@ -42,12 +44,10 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma attachmentHeight = len(email.Attachments) + 2 } - // Build viewport with initial size and set wrapped content to fit the width. + // Build viewport with initial size and set wrapped content. vp := viewport.New(width, height-headerHeight-attachmentHeight) - - // Wrap the body to the viewport width so lines don't run off-screen. wrapped := wrapBodyToWidth(body, vp.Width) - vp.SetContent(wrapped) + vp.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n") return &EmailView{ viewport: vp, @@ -74,6 +74,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusOnAttachments = false return m, nil } + m.viewport.SetContent("\x1b_Ga=d\x1b\\") return m, func() tea.Msg { return BackToMailboxMsg{Mailbox: m.mailbox} } } @@ -139,13 +140,14 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.Width = msg.Width m.viewport.Height = msg.Height - headerHeight - attachmentHeight - // When the window size changes, rewrap the body to the new width - body, err := view.ProcessBody(m.email.Body, H1Style, H2Style, BodyStyle) + // When the window size changes, wrap and clear kitty images to keep placement stable + inlineImages := inlineImagesFromAttachments(m.email.Attachments) + body, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } wrapped := wrapBodyToWidth(body, m.viewport.Width) - m.viewport.SetContent(wrapped) + m.viewport.SetContent("\x1b_Ga=d\x1b\\\n" + wrapped + "\n") } m.viewport, cmd = m.viewport.Update(msg) @@ -190,6 +192,20 @@ func (m *EmailView) GetAccountID() string { return m.accountID } +func inlineImagesFromAttachments(atts []fetcher.Attachment) []view.InlineImage { + var imgs []view.InlineImage + for _, att := range atts { + if !att.Inline || len(att.Data) == 0 || att.ContentID == "" { + continue + } + imgs = append(imgs, view.InlineImage{ + CID: att.ContentID, + Base64: base64.StdEncoding.EncodeToString(att.Data), + }) + } + return imgs +} + func wrapBodyToWidth(body string, width int) string { return BodyStyle.Width(width).Render(body) } diff --git a/tui/inbox.go b/tui/inbox.go index 166011a9b34c79d2adf0bfca8bd3a36593dd06b8..b4b4ce579c5266e148167f268967849af67ff8f3 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -458,7 +458,7 @@ func (m *Inbox) View() string { } b.WriteString(m.list.View()) - return "\n" + b.String() + return "\x1b_Ga=d\x1b\\\\\n" + b.String() } // GetCurrentAccountID returns the currently selected account ID diff --git a/view/html.go b/view/html.go index 436ddff54a7edea1392ba2a59df4314f4ac609ae..8813b19b03037ee8c2d282b8943fbf1f60096947 100644 --- a/view/html.go +++ b/view/html.go @@ -2,18 +2,82 @@ package view import ( "bytes" + "encoding/base64" "fmt" + "image" + "image/png" "io" "mime/quotedprintable" + "net/http" + "os" "regexp" "strings" + "time" + + _ "image/gif" + _ "image/jpeg" "github.com/PuerkitoBio/goquery" "github.com/charmbracelet/lipgloss" "github.com/yuin/goldmark" "github.com/yuin/goldmark/renderer/html" + "golang.org/x/sys/unix" ) +// getTerminalCellSize returns the height of a terminal cell in pixels. +// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions. +// Falls back to a default of 18 pixels if the query fails. +func getTerminalCellSize() int { + const defaultCellHeight = 18 + + // Try stdout, stdin, stderr, then /dev/tty as last resort + fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())} + + for _, fd := range fds { + if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 { + return cellHeight + } + } + + // Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea) + if tty, err := os.Open("/dev/tty"); err == nil { + defer tty.Close() + if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 { + return cellHeight + } + } + + debugKitty("using default cell height: %d pixels", defaultCellHeight) + return defaultCellHeight +} + +// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor. +// Returns 0 if it fails or if pixel dimensions are not available. +func getCellHeightFromFd(fd int) int { + ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ) + if err != nil { + return 0 + } + + // ws.Row = number of character rows + // ws.Ypixel = height in pixels + // Some terminals don't report pixel dimensions (return 0) + if ws.Row > 0 && ws.Ypixel > 0 { + cellHeight := int(ws.Ypixel) / int(ws.Row) + if cellHeight > 0 { + debugKitty("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd) + return cellHeight + } + } + + // Terminal reported dimensions but no pixel info - this is common + if ws.Row > 0 && ws.Ypixel == 0 { + debugKitty("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row) + } + + return 0 +} + // hyperlink formats a string as a terminal-clickable hyperlink. func hyperlink(url, text string) string { if text == "" { @@ -45,9 +109,176 @@ func markdownToHTML(md []byte) []byte { return buf.Bytes() } +func kittySupported() bool { + term := strings.ToLower(os.Getenv("TERM")) + if strings.Contains(term, "kitty") { + return true + } + return os.Getenv("KITTY_WINDOW_ID") != "" +} + +func debugKitty(format string, args ...interface{}) { + if os.Getenv("DEBUG_KITTY_IMAGES") == "" { + return + } + msg := fmt.Sprintf("[kitty-img] "+format+"\n", args...) + fmt.Print(msg) + if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" { + if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { + _, _ = f.WriteString(msg) + _ = f.Close() + } + } +} + +func fetchRemoteBase64(url string) string { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return "" + } + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get(url) + if err != nil { + debugKitty("remote fetch failed url=%s err=%v", url, err) + return "" + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + debugKitty("remote fetch non-200 url=%s status=%d", url, resp.StatusCode) + return "" + } + data, err := io.ReadAll(resp.Body) + if err != nil { + debugKitty("remote fetch read error url=%s err=%v", url, err) + return "" + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + debugKitty("remote decode failed url=%s err=%v", url, err) + return "" + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + debugKitty("remote png encode failed url=%s err=%v", url, err) + return "" + } + + encoded := base64.StdEncoding.EncodeToString(buf.Bytes()) + debugKitty("remote fetch ok url=%s len=%d", url, len(encoded)) + return encoded +} + +func dataURIBase64(uri string) string { + if !strings.HasPrefix(uri, "data:") { + return "" + } + comma := strings.Index(uri, ",") + if comma == -1 || comma+1 >= len(uri) { + return "" + } + return uri[comma+1:] +} + +// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted. +// This prevents the newline-collapsing regex from removing intentional spacing. +// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags. +const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:" +const imageRowPlaceholderSuffix = "]]" + +func kittyInlineImage(payload string) string { + if payload == "" { + return "" + } + + const chunkSize = 4096 + var b strings.Builder + + // Calculate how many terminal rows the image occupies to advance text after it. + rows := 1 + if data, err := base64.StdEncoding.DecodeString(payload); err == nil { + if img, _, err := image.Decode(bytes.NewReader(data)); err == nil { + cellHeight := getTerminalCellSize() + h := img.Bounds().Dy() + rows = (h + cellHeight - 1) / cellHeight + if rows < 1 { + rows = 1 + } + debugKitty("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows) + } + } + + for offset := 0; offset < len(payload); offset += chunkSize { + end := offset + chunkSize + if end > len(payload) { + end = len(payload) + } + more := "0" + if end < len(payload) { + more = "1" + } + + chunk := payload[offset:end] + if offset == 0 { + // C=1 means cursor does NOT move after image render (stays at top-left of image position) + // This is needed for proper TUI rendering, but we must add newlines to push text below + b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk)) + } else { + b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk)) + } + } + + // Add newlines to push cursor below the image. + // Use a placeholder that won't be collapsed by the newline regex. + b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)) + + return b.String() +} + +// expandImageRowPlaceholders replaces image row placeholders with actual newlines. +func expandImageRowPlaceholders(text string) string { + re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix)) + return re.ReplaceAllStringFunc(text, func(match string) string { + // Extract the number of rows from the placeholder + numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix) + numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix) + rows := 1 + if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 { + rows = 1 + } + // Return the newlines needed to push content below the image + return strings.Repeat("\n", rows) + }) +} + +type InlineImage struct { + CID string + Base64 string +} + +// ProcessBodyWithInline renders the body and resolves CID inline images when provided. +func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { + inlineMap := make(map[string]string, len(inline)) + for _, img := range inline { + cid := strings.TrimSpace(img.CID) + cid = strings.TrimPrefix(cid, "<") + cid = strings.TrimSuffix(cid, ">") + cid = strings.TrimPrefix(cid, "cid:") + if cid == "" || img.Base64 == "" { + continue + } + inlineMap[cid] = img.Base64 + } + return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle) +} + // ProcessBody takes a raw email body, decodes it, and formats it as plain // text with terminal hyperlinks. func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { + return processBody(rawBody, nil, h1Style, h2Style, bodyStyle) +} + +func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { decodedBody, err := decodeQuotedPrintable(rawBody) if err != nil { decodedBody = rawBody @@ -73,7 +304,6 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st }) // Add newlines after block elements for better spacing. - // THIS IS THE KEY FIX: Include h1 and h2 in the selector. doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) { s.After("\n\n") }) @@ -101,15 +331,49 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st if alt == "" { alt = "Does not contain alt text" } + + if kittySupported() { + var payload string + if strings.HasPrefix(src, "data:image/") { + payload = dataURIBase64(src) + } else if strings.HasPrefix(src, "cid:") { + cid := strings.TrimPrefix(src, "cid:") + cid = strings.Trim(cid, "<>") + if inline != nil { + payload = inline[cid] + debugKitty("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload)) + } else { + debugKitty("cid lookup skipped inline map nil for %s", cid) + } + } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") { + payload = fetchRemoteBase64(src) + } + + if payload != "" { + if rendered := kittyInlineImage(payload); rendered != "" { + debugKitty("rendered inline image src=%s len=%d dataURI=%t cid=%t", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:")) + s.ReplaceWithHtml("\n" + rendered + "\n") + return + } + debugKitty("payload present but renderer returned empty src=%s len=%d", src, len(payload)) + } else { + debugKitty("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:")) + } + } else { + debugKitty("kitty not detected for src=%s", src) + } + s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt))) }) text := doc.Text() + // Collapse excessive newlines, but not the image row placeholders re := regexp.MustCompile(`\n{3,}`) text = re.ReplaceAllString(text, "\n\n") - text = strings.TrimSpace(text) + // Now expand the image row placeholders to actual newlines + text = expandImageRowPlaceholders(text) return bodyStyle.Render(text), nil }