viewer: add html-inline-images option

Add a new html-inline-images option in the [viewer] section that enables
inlining of images referenced by <img> tags with cid: URLs in HTML emails.

When enabled, aerc will parse HTML content to find <img src="cid:...">
references, fetch the corresponding image parts using their Content-ID
and use base64 encoding to embed images directly in HTML using data:
URLs.

This allows HTML emails with embedded images to be properly viewed in
w3m and other browsers that support data: URLs. The implementation uses
asynchronous callbacks to fetch all images in parallel without blocking.

The feature works with all aerc commands that fetch message parts (:save,
:open, :pipe, and viewing). Updated the filters/html script to enable
w3m image support with sixel graphics when img2sixel is available.

Add documentation for the new html-inline-images viewer option in both
the default aerc.conf and the aerc-config(5) man page.

Implements: https://todo.sr.ht/~rjarry/aerc/252
Changelog-added: New `[viewer].html-inline-images` option to replace
 `<img src="cid:...">` tags in `text/html` parts with their related
 `image/*` part data encoded in base64. For this to work with sixel
 compatible terminals, you need to update your filters with `text/html =
 ! html -sixel` and install `img2sixel`.
Signed-off-by: Robin Jarry <robin@jarry.cc>
Tested-by: Bence Ferdinandy <bence@ferdinandy.com>
Tested-by: Inwit <inwit@sindominio.net>
Tested-by: Matthew Phillips <matthew@matthewphillips.info>
This commit is contained in:
Robin Jarry
2025-12-01 11:13:43 +01:00
parent dab1d48a25
commit 8fee1477b7
6 changed files with 262 additions and 10 deletions

View File

@@ -655,6 +655,17 @@
# Default: true
#parse-http-links=true
# Enable inlining of images referenced by <img> tags with cid: URLs in HTML
# emails. When enabled, aerc will fetch image parts referenced by their
# Content-ID and replace cid: URLs with base64-encoded data: URLs. This allows
# HTML emails with embedded images to be properly viewed in browsers.
#
# Works with the builtin html filter when using the -sixel option (requires
# img2sixel to be installed). Also works with :save, :open, and :pipe commands.
#
# Default: false
#html-inline-images=false
[compose]
#
# Specifies the command to run the editor with. It will be shown in an embedded

View File

@@ -11,14 +11,15 @@ import (
)
type ViewerConfig struct {
Pager string `ini:"pager" default:"less -Rc"`
Alternatives []string `ini:"alternatives" default:"text/plain,text/html" delim:","`
ShowHeaders bool `ini:"show-headers"`
AlwaysShowMime bool `ini:"always-show-mime"`
MaxMimeHeight int `ini:"max-mime-height" default:"0"`
ParseHttpLinks bool `ini:"parse-http-links" default:"true"`
HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"From|To,Cc|Bcc,Date,Subject"`
KeyPassthrough bool
Pager string `ini:"pager" default:"less -Rc"`
Alternatives []string `ini:"alternatives" default:"text/plain,text/html" delim:","`
ShowHeaders bool `ini:"show-headers"`
AlwaysShowMime bool `ini:"always-show-mime"`
MaxMimeHeight int `ini:"max-mime-height" default:"0"`
ParseHttpLinks bool `ini:"parse-http-links" default:"true"`
HtmlInlineImages bool `ini:"html-inline-images"`
HeaderLayout [][]string `ini:"header-layout" parse:"ParseLayout" default:"From|To,Cc|Bcc,Date,Subject"`
KeyPassthrough bool
// private
contextualViewers []*ViewerConfigContext

View File

@@ -884,6 +884,23 @@ These options are configured in the *[viewer]* section of _aerc.conf_.
Default: _true_
*html-inline-images* = _true_|_false_
Enable inlining of images referenced by _<img>_ tags with _cid:_ URLs
in HTML emails. When enabled, aerc will fetch image parts referenced by
their Content-ID and replace _cid:_ URLs with base64-encoded _data:_
URLs. This allows HTML emails with embedded images to be properly viewed
in browsers.
The feature works with all aerc commands that fetch message parts
(*:save*, *:open*, *:pipe*, and viewing).
For w3m, images will be displayed when using the *-sixel* option,
which requires *img2sixel*(1) to be installed. Make sure your
_text/html_ filter in *[filters]* is configured to use w3m with
appropriate image settings (e.g.: *text/html* = _! html -sixel_).
Default: _false_
## CONTEXTUAL VIEWER CONFIGURATION
The viewer configuration can be specialized for senders and message

1
go.mod
View File

@@ -31,6 +31,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/syndtr/goleveldb v1.0.0
golang.org/x/image v0.30.0
golang.org/x/net v0.43.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.35.0
golang.org/x/tools v0.36.0

196
lib/inlineimages.go Normal file
View File

@@ -0,0 +1,196 @@
package lib
import (
"bytes"
"encoding/base64"
"io"
"maps"
"strings"
"sync"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"golang.org/x/net/html"
)
// InlineHTMLImages transforms an HTML email by replacing <img> tags with cid:
// URLs with base64-encoded data: URLs. This allows HTML emails with embedded
// images to be viewed in browsers that support data: URLs, including w3m when
// used with the -sixel option and img2sixel installed.
//
// This function uses callbacks and will call the provided callback
// asynchronously once all images have been fetched and inlined.
func InlineHTMLImages(
htmlReader io.Reader,
msg MessageView,
callback func(io.Reader),
) {
// Read the HTML content
htmlBytes, err := io.ReadAll(htmlReader)
if err != nil {
log.Errorf("Failed to read HTML: %v", err)
callback(bytes.NewReader(htmlBytes))
return
}
// Parse the HTML
doc, err := html.Parse(bytes.NewReader(htmlBytes))
if err != nil {
log.Errorf("Failed to parse HTML: %v", err)
callback(bytes.NewReader(htmlBytes))
return
}
// Build a map of Content-ID to part index
cidMap := buildContentIDMap(msg.BodyStructure(), []int{})
// If no Content-IDs found, return the original HTML
if len(cidMap) == 0 {
log.Tracef("No Content-IDs found in message, skipping image inlining")
callback(bytes.NewReader(htmlBytes))
return
}
// Find all cid: references in img tags
cidReferences := make(map[string]bool)
var findCids func(*html.Node)
findCids = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "img" {
for _, attr := range n.Attr {
if attr.Key == "src" && strings.HasPrefix(attr.Val, "cid:") {
cid := strings.Trim(strings.TrimPrefix(attr.Val, "cid:"), "<>")
if _, ok := cidMap[cid]; ok {
cidReferences[cid] = true
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
findCids(c)
}
}
findCids(doc)
if len(cidReferences) == 0 {
log.Tracef("No cid: references found in HTML, returning original")
callback(bytes.NewReader(htmlBytes))
return
}
log.Tracef("Found %d cid: references to inline", len(cidReferences))
// Fetch all images asynchronously and encode as data: URLs
var wg sync.WaitGroup
var mu sync.Mutex
imageURLs := make(map[string]string)
for cid := range cidReferences {
info := cidMap[cid]
wg.Add(1)
msg.FetchBodyPart(info.index, func(reader io.Reader) {
defer wg.Done()
data, err := io.ReadAll(reader)
if err != nil {
log.Errorf("Failed to read image part for CID %s: %v", cid, err)
return
}
// Encode as base64
encoded := base64.StdEncoding.EncodeToString(data)
// Create data: URL
dataURL := "data:" + info.mimeType + ";base64," + encoded
mu.Lock()
imageURLs[cid] = dataURL
mu.Unlock()
log.Tracef("Encoded image with Content-ID %s as data: URL (%d bytes)", cid, len(data))
})
}
// Wait for all images to be fetched, then transform HTML
go func() {
defer log.PanicHandler()
wg.Wait()
// Replace all cid: references with data: URLs
modified := false
var processNode func(*html.Node)
processNode = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "img" {
for i, attr := range n.Attr {
if attr.Key == "src" && strings.HasPrefix(attr.Val, "cid:") {
cid := strings.Trim(strings.TrimPrefix(attr.Val, "cid:"), "<>")
if dataURL, ok := imageURLs[cid]; ok {
n.Attr[i].Val = dataURL
modified = true
log.Tracef("Replaced cid:%s with data: URL", cid)
}
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
processNode(c)
}
}
processNode(doc)
if !modified {
log.Warnf("No images were inlined despite finding CID references")
callback(bytes.NewReader(htmlBytes))
return
}
// Render the modified HTML back to bytes
var buf bytes.Buffer
err = html.Render(&buf, doc)
if err != nil {
log.Errorf("Failed to render HTML: %v", err)
callback(bytes.NewReader(htmlBytes))
return
}
callback(&buf)
}()
}
// partInfo stores the index and MIME type of a part
type partInfo struct {
index []int
mimeType string
}
// buildContentIDMap recursively builds a map from Content-ID to part information
func buildContentIDMap(bs *models.BodyStructure, index []int) map[string]partInfo {
result := make(map[string]partInfo)
if bs == nil {
log.Tracef("buildContentIDMap: body structure is nil")
return result
}
// Log the current part being examined
log.Tracef("buildContentIDMap: examining part index=%v mime=%s contentid=%q numParts=%d",
index, bs.FullMIMEType(), bs.ContentID, len(bs.Parts))
// Add this part if it has a Content-ID
if bs.ContentID != "" {
result[strings.Trim(bs.ContentID, "<>")] = partInfo{
index: append([]int{}, index...),
mimeType: bs.FullMIMEType(),
}
log.Tracef("Found Content-ID: %s at index %v (%s)",
bs.ContentID, index, bs.FullMIMEType())
}
// Recursively process child parts
for i, part := range bs.Parts {
childIndex := append(append([]int{}, index...), i+1)
maps.Copy(result, buildContentIDMap(part, childIndex))
}
return result
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/ProtonMail/go-crypto/openpgp"
_ "github.com/emersion/go-message/charset"
"git.sr.ht/~rjarry/aerc/config"
"git.sr.ht/~rjarry/aerc/lib/crypto"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/lib/rfc822"
@@ -156,8 +157,21 @@ func (msv *MessageStoreView) FetchFull(cb func(io.Reader)) {
}
func (msv *MessageStoreView) FetchBodyPart(part []int, cb func(io.Reader)) {
// Check if we should inline images for HTML parts
viewerConfig := config.Viewer().ForEnvelope(msv.messageInfo.Envelope)
// Wrap the callback to apply HTML transformation if needed
wrappedCb := cb
if viewerConfig.HtmlInlineImages && msv.isHTMLPart(part) {
wrappedCb = func(reader io.Reader) {
// InlineHTMLImages will call our callback
// asynchronously after fetching all images
InlineHTMLImages(reader, msv, cb)
}
}
if msv.message == nil && msv.messageStore != nil {
msv.messageStore.FetchBodyPart(msv.messageInfo.Uid, part, cb)
msv.messageStore.FetchBodyPart(msv.messageInfo.Uid, part, wrappedCb)
return
}
@@ -177,5 +191,17 @@ func (msv *MessageStoreView) FetchBodyPart(part []int, cb func(io.Reader)) {
reader = strings.NewReader(errMsg.Error())
}
}
cb(reader)
wrappedCb(reader)
}
// isHTMLPart returns true if the given part index refers to a text/html part
func (msv *MessageStoreView) isHTMLPart(part []int) bool {
if msv.bodyStructure == nil {
return false
}
partStruct, err := msv.bodyStructure.PartAtIndex(part)
if err != nil {
return false
}
return partStruct.FullMIMEType() == "text/html"
}