smtp2shoutrrr/config.go
butterrobot d2fc783c6e
All checks were successful
CI / goreleaser-lint (pull_request) Successful in 3s
CI / format (pull_request) Successful in 2m36s
CI / test (pull_request) Successful in 1m53s
CI / lint (pull_request) Successful in 5m39s
CI / build (pull_request) Successful in 5m35s
feat: add a per-recipient Format option to convert HTML bodies (FMG-9)
Targets that render no HTML — a Mattermost direct message, for one — used to
receive the markup of an HTML-only message verbatim. `Format` on a recipient
(or on `[CatchAll]`) now renders an HTML body as `text` or `markdown` before
it is forwarded; the default `raw` keeps forwarding the message unchanged.

Only an HTML body is ever rewritten: a message that arrived as plain text is
what the sender chose to write, and is forwarded untouched whatever the
recipient asked for.

Converting a body first required reading it correctly, which fixes three
faults that were also spoiling raw delivery:

- Content-Transfer-Encoding was never undone outside multipart parts, so a
  quoted-printable or base64 body reached the target as "=E2=80=99".
- A non-UTF-8 charset was forwarded as its raw bytes, turning every accented
  character into mojibake.
- The multipart walk did not descend into nested containers, so the
  multipart/mixed wrapping a multipart/alternative that every forwarded
  message is produced an empty notification.

Attachments are now skipped when picking the body, an empty text/plain
alternative no longer wins over the HTML the sender actually wrote, and a
multipart Content-Type without a boundary is refused as malformed rather than
silently yielding nothing.

The renderer is built on golang.org/x/net/html, already an indirect
dependency, and aims at chat and push notifications rather than at
reproducing the document: links keep their destination, images without alt
text and hidden preheaders are dropped, and layout tables become one line per
row. A body too deeply nested for the parser is forwarded unchanged, since
reformatting is a courtesy to the target rather than a condition of delivery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:58:52 +00:00

180 lines
5 KiB
Go

package smtp2shoutrrr
import (
"bytes"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"github.com/pelletier/go-toml/v2"
)
// LoadConfig reads the TOML configuration at path, applies its defaults and
// checks that it can actually deliver something.
func LoadConfig(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file %q: %w", path, err)
}
var config Config
if err := toml.Unmarshal(raw, &config); err != nil {
return nil, fmt.Errorf("decoding config file %q: %w", path, err)
}
warnUnknownKeys(path, raw)
config.SetDefaults()
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config file %q: %w", path, err)
}
return &config, nil
}
// warnUnknownKeys reports keys the decode silently ignored. Rejecting them
// would break configurations that already carry a stray key, but dropping them
// without a word hides the mistypes ([[Recipient]], Adresses) that otherwise
// produce a server which accepts mail and forwards none of it.
func warnUnknownKeys(path string, raw []byte) {
var discard Config
var strict *toml.StrictMissingError
if !errors.As(toml.NewDecoder(bytes.NewReader(raw)).DisallowUnknownFields().Decode(&discard), &strict) {
return
}
for i := range strict.Errors {
slog.Warn("ignoring unknown key in config file",
slog.String("path", path),
slog.String("key", strings.Join(strict.Errors[i].Key(), ".")))
}
}
type Config struct {
Port int
Username string
Password string
Recipients []ConfigRecipient
CatchAll *ConfigRecipient
}
func (c *Config) SetDefaults() {
if c.Port == 0 {
c.Port = 11125
}
for i := range c.Recipients {
c.Recipients[i].Format = c.Recipients[i].Format.normalize()
}
if c.CatchAll != nil {
c.CatchAll.Format = c.CatchAll.Format.normalize()
}
}
// Validate rejects configuration that would leave the server relaying for
// anyone, or accepting mail it can never forward. A TOML decode ignores
// unknown keys, so a mistyped table or field name ([[Recipient]], Adresses)
// otherwise yields a server that answers 250 OK to everything and sends
// nothing.
func (c *Config) Validate() error {
// Credentials used to fall back to username/password when unset, which
// left the AUTH gate open to the most obvious guess there is — for
// exactly the deployments that had configured the least.
if c.Username == "" {
return errors.New("no Username configured")
}
if c.Password == "" {
return errors.New("no Password configured")
}
for i := range c.Recipients {
r := &c.Recipients[i]
if len(r.Addresses) == 0 {
return fmt.Errorf("recipient %d has no Addresses", i)
}
if len(r.GetTargetURLs()) == 0 {
return fmt.Errorf("recipient %d (%s) has no usable Targets",
i, strings.Join(r.Addresses, ","))
}
if !r.Format.valid() {
return fmt.Errorf("recipient %d (%s) has an unknown Format %q, expected one of: %s",
i, strings.Join(r.Addresses, ","), r.Format, formatNames())
}
}
if c.CatchAll != nil {
if len(c.CatchAll.GetTargetURLs()) == 0 {
return errors.New("CatchAll has no usable Targets")
}
if !c.CatchAll.Format.valid() {
return fmt.Errorf("CatchAll has an unknown Format %q, expected one of: %s",
c.CatchAll.Format, formatNames())
}
}
if len(c.Recipients) == 0 && c.CatchAll == nil {
return errors.New("no Recipients and no CatchAll configured")
}
return nil
}
type ConfigRecipient struct {
Addresses []string // email addresses
Target string // deprecated: use Targets instead
Targets []string // shoutrrr addresses (supports multiple)
// Format is the representation these targets want an HTML message body
// in: raw (the default, forwarding the message unchanged), text or
// markdown. Targets that render neither HTML nor Markdown — a Mattermost
// direct message, for one — otherwise receive the markup verbatim.
Format BodyFormat
targetURLs []*url.URL // cached parsed URLs
}
func (cr *ConfigRecipient) GetTargetURLs() []*url.URL {
if cr.targetURLs == nil {
cr.targetURLs = make([]*url.URL, 0)
// Collect all targets, merging Target into Targets
allTargets := make([]string, 0)
// Handle deprecated Target field
if cr.Target != "" {
allTargets = append(allTargets, cr.Target)
slog.Warn("Target field is deprecated, use Targets instead",
slog.String("addresses", strings.Join(cr.Addresses, ",")))
}
// Add all Targets
allTargets = append(allTargets, cr.Targets...)
// Parse and cache all URLs
for _, target := range allTargets {
parsedURL, err := url.Parse(target)
if err != nil {
slog.Error("failed to parse shoutrrr target URL",
slog.String("target", target),
slog.String("err", err.Error()))
continue // Skip invalid URLs
}
// url.Parse accepts almost any string, so the scheme is what
// actually distinguishes a shoutrrr target from a typo.
if parsedURL.Scheme == "" {
slog.Error("shoutrrr target URL has no scheme",
slog.String("target", target))
continue
}
cr.targetURLs = append(cr.targetURLs, parsedURL)
}
}
return cr.targetURLs
}