All checks were successful
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>
180 lines
5 KiB
Go
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
|
|
}
|