1
0
Fork 0
mirror of https://git.sr.ht/~rjarry/aerc synced 2026-01-02 17:31:17 +01:00
aerc/config/viewer.go
Robin Jarry 8fee1477b7 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>
2025-12-04 13:52:37 +01:00

194 lines
5.1 KiB
Go

package config
import (
"regexp"
"sync/atomic"
"git.sr.ht/~rjarry/aerc/lib/log"
"git.sr.ht/~rjarry/aerc/models"
"github.com/emersion/go-message/mail"
"github.com/go-ini/ini"
)
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"`
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
contextualCounts map[viewerContextType]int
contextualCache map[viewerContextKey]*ViewerConfig
}
type viewerContextType int
const (
viewerContextSender viewerContextType = iota
viewerContextFrom
viewerContextSubject
)
type ViewerConfigContext struct {
ContextType viewerContextType
Value string
Regex *regexp.Regexp
ViewerConfig *ViewerConfig
Section ini.Section
}
type viewerContextKey struct {
ctxType viewerContextType
value string
}
var viewerConfig atomic.Pointer[ViewerConfig]
func Viewer() *ViewerConfig {
return viewerConfig.Load()
}
var viewerContextualSectionRe = regexp.MustCompile(`^viewer:(sender|from|subject)([~=])(.+)$`)
func parseViewer(file *ini.File) (*ViewerConfig, error) {
conf := &ViewerConfig{
contextualCounts: make(map[viewerContextType]int),
contextualCache: make(map[viewerContextKey]*ViewerConfig),
}
if err := conf.parse(file.Section("viewer"), true); err != nil {
return nil, err
}
for _, section := range file.Sections() {
var err error
groups := viewerContextualSectionRe.FindStringSubmatch(section.Name())
if groups == nil {
continue
}
ctx, separator, value := groups[1], groups[2], groups[3]
viewerSubConfig := ViewerConfig{}
if err = viewerSubConfig.parse(section, false); err != nil {
return nil, err
}
contextualViewer := ViewerConfigContext{
ViewerConfig: &viewerSubConfig,
Section: *section,
}
switch ctx {
case "sender":
contextualViewer.ContextType = viewerContextSender
case "from":
contextualViewer.ContextType = viewerContextFrom
case "subject":
contextualViewer.ContextType = viewerContextSubject
}
if separator == "=" {
contextualViewer.Value = value
} else {
contextualViewer.Regex, err = regexp.Compile(value)
if err != nil {
return nil, err
}
}
conf.contextualViewers = append(conf.contextualViewers, &contextualViewer)
conf.contextualCounts[contextualViewer.ContextType]++
}
log.Debugf("aerc.conf: [viewer] %#v", conf)
return conf, nil
}
func (config *ViewerConfig) parse(section *ini.Section, useDefaults bool) error {
return MapToStruct(section, config, useDefaults)
}
func (v *ViewerConfig) ParseLayout(sec *ini.Section, key *ini.Key) ([][]string, error) {
layout := parseLayout(key.String())
return layout, nil
}
func (base *ViewerConfig) mergeContextual(
contextType viewerContextType, matcher func(*ViewerConfigContext) bool,
) *ViewerConfig {
for _, contextualViewer := range base.contextualViewers {
if contextualViewer.ContextType != contextType {
continue
}
if !matcher(contextualViewer) {
continue
}
viewer := *base
err := viewer.parse(&contextualViewer.Section, false)
if err != nil {
log.Warnf("merge viewer failed: %v", err)
}
viewer.contextualCache = make(map[viewerContextKey]*ViewerConfig)
viewer.contextualCounts = base.contextualCounts
viewer.contextualViewers = base.contextualViewers
return &viewer
}
return base
}
func (base *ViewerConfig) contextual(
ctxType viewerContextType, ctxKey string, matcher func(*ViewerConfigContext) bool,
) *ViewerConfig {
if base.contextualCounts[ctxType] == 0 {
// shortcut if no contextual viewer for that type
return base
}
key := viewerContextKey{ctxType: ctxType, value: ctxKey}
ctx, found := base.contextualCache[key]
if !found {
ctx = base.mergeContextual(ctxType, matcher)
base.contextualCache[key] = ctx
}
return ctx
}
func makeMatcher(
valueTarget string, regexTarget string,
) func(ctx *ViewerConfigContext) bool {
return func(ctx *ViewerConfigContext) bool {
if ctx.Value != "" && ctx.Value == valueTarget {
return true
}
if ctx.Regex != nil && ctx.Regex.Match([]byte(regexTarget)) {
return true
}
return false
}
}
func (base *ViewerConfig) forAddresses(
ctxType viewerContextType, addresses []*mail.Address,
) *ViewerConfig {
for _, address := range addresses {
base = base.contextual(
ctxType, address.String(),
makeMatcher(address.Address, address.String()),
)
}
return base
}
func (base *ViewerConfig) ForEnvelope(envelope *models.Envelope) *ViewerConfig {
if envelope == nil {
return base
}
base = base.forAddresses(viewerContextSender, envelope.Sender)
base = base.forAddresses(viewerContextFrom, envelope.From)
return base.contextual(
viewerContextSubject, envelope.Subject,
makeMatcher(envelope.Subject, envelope.Subject),
)
}