Files
felhom-controller/controller/internal/stacks/mailenv_test.go
T
admin a405505e81 v0.89.0: app-email plaintext-only listener (:2526) + split-From mapping
Gap 1: third shim listener :2526, plaintext, does NOT advertise STARTTLS (TLSConfig
nil) — for opportunistic-STARTTLS clients with no cert-skip (cal.com, nextcloud).
Gap 2: SMTPMapping tls_mode (picks port 2525/2526/2465) + from_domain_var (split
local-part + domain for nextcloud's MAIL_FROM_ADDRESS/MAIL_DOMAIN). Default keeps
existing apps on 2525. Hub untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:13:40 +02:00

241 lines
7.5 KiB
Go

package stacks
import (
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
func newMailManager(t *testing.T, globalOn bool, fromName string) *Manager {
t.Helper()
lg := log.New(os.Stderr, "", 0)
cfg := &config.Config{}
cfg.MailRelay.ShimHost = "felhom-controller"
cfg.MailRelay.FromDomains = []string{"felhom.eu"}
m := &Manager{cfg: cfg, logger: lg, stacks: map[string]*Stack{}}
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if globalOn {
if err := sett.SetAppEmail(true, fromName); err != nil {
t.Fatal(err)
}
}
m.settings = sett
return m
}
func vaultwardenMeta() *Metadata {
return &Metadata{
Slug: "vaultwarden",
DisplayName: "Vaultwarden",
SMTPMapping: &SMTPMapping{
HostVar: "SMTP_HOST",
PortVar: "SMTP_PORT",
SecurityVar: "SMTP_SECURITY",
SecurityValue: "starttls",
FromVar: "SMTP_FROM",
FromNameVar: "SMTP_FROM_NAME",
Extra: map[string]string{
"SMTP_ACCEPT_INVALID_CERTS": "true",
"SMTP_ACCEPT_INVALID_HOSTNAMES": "true",
},
},
}
}
func envMap(kvs []string) map[string]string {
m := make(map[string]string, len(kvs))
for _, kv := range kvs {
if i := strings.IndexByte(kv, '='); i >= 0 {
m[kv[:i]] = kv[i+1:]
}
}
return m
}
// §7 A / §8: global ON + app ON + mapping → full mapped env injected.
func TestSMTPEnv_BothTogglesOn_Injects(t *testing.T) {
m := newMailManager(t, true, "")
got := envMap(m.smtpEnv(vaultwardenMeta(), true))
want := map[string]string{
"SMTP_HOST": "felhom-controller",
"SMTP_PORT": "2525",
"SMTP_SECURITY": "starttls",
"SMTP_FROM": "vaultwarden@felhom.eu",
"SMTP_FROM_NAME": "Vaultwarden",
"SMTP_ACCEPT_INVALID_CERTS": "true",
"SMTP_ACCEPT_INVALID_HOSTNAMES": "true",
}
for k, v := range want {
if got[k] != v {
t.Errorf("env %s = %q, want %q", k, got[k], v)
}
}
}
// §7 E / §8: per-app OFF → nothing injected even when global is ON.
func TestSMTPEnv_PerAppOff_NoInjection(t *testing.T) {
m := newMailManager(t, true, "")
if got := m.smtpEnv(vaultwardenMeta(), false); got != nil {
t.Fatalf("per-app off must inject nothing, got %v", got)
}
}
// §7 E / §8: global OFF → nothing injected even when the app toggle is ON.
func TestSMTPEnv_GlobalOff_NoInjection(t *testing.T) {
m := newMailManager(t, false, "")
if got := m.smtpEnv(vaultwardenMeta(), true); got != nil {
t.Fatalf("global off must inject nothing, got %v", got)
}
}
// §8: app with no smtp_mapping → nothing injected.
func TestSMTPEnv_NoMapping_NoInjection(t *testing.T) {
m := newMailManager(t, true, "")
meta := &Metadata{Slug: "plex", DisplayName: "Plex"}
if got := m.smtpEnv(meta, true); got != nil {
t.Fatalf("app without smtp_mapping must inject nothing, got %v", got)
}
}
// Household display-name override wins over the app display name.
func TestSMTPEnv_HouseholdFromNameWins(t *testing.T) {
m := newMailManager(t, true, "Kovács család")
got := envMap(m.smtpEnv(vaultwardenMeta(), true))
if got["SMTP_FROM_NAME"] != "Kovács család" {
t.Fatalf("SMTP_FROM_NAME = %q, want household name", got["SMTP_FROM_NAME"])
}
}
// Mealie-style mapping: different env keys + from_local + STARTTLS expressed as "TLS".
func TestSMTPEnv_MealieMapping(t *testing.T) {
m := newMailManager(t, true, "")
meta := &Metadata{
Slug: "mealie",
DisplayName: "Mealie",
SMTPMapping: &SMTPMapping{
HostVar: "SMTP_HOST",
PortVar: "SMTP_PORT",
SecurityVar: "SMTP_AUTH_STRATEGY",
SecurityValue: "TLS",
FromVar: "SMTP_FROM_EMAIL",
FromNameVar: "SMTP_FROM_NAME",
FromLocal: "mealie",
},
}
got := envMap(m.smtpEnv(meta, true))
if got["SMTP_AUTH_STRATEGY"] != "TLS" {
t.Errorf("SMTP_AUTH_STRATEGY = %q, want TLS", got["SMTP_AUTH_STRATEGY"])
}
if got["SMTP_FROM_EMAIL"] != "mealie@felhom.eu" {
t.Errorf("SMTP_FROM_EMAIL = %q, want mealie@felhom.eu", got["SMTP_FROM_EMAIL"])
}
if got["SMTP_PORT"] != "2525" {
t.Errorf("SMTP_PORT = %q, want 2525", got["SMTP_PORT"])
}
}
// §10: port chosen by tls_mode. plaintext→2526, starttls/empty→2525, implicit-tls→2465.
func TestSMTPEnv_PortByTLSMode(t *testing.T) {
m := newMailManager(t, true, "")
cases := map[string]string{"": "2525", "starttls": "2525", "plaintext": "2526", "implicit-tls": "2465"}
for mode, wantPort := range cases {
meta := vaultwardenMeta()
meta.SMTPMapping.TLSMode = mode
got := envMap(m.smtpEnv(meta, true))
if got["SMTP_PORT"] != wantPort {
t.Errorf("tls_mode=%q → SMTP_PORT=%q, want %q", mode, got["SMTP_PORT"], wantPort)
}
}
}
// §10 companion: shimPortForTLSMode must actually branch on the mode (not always 2525).
func TestShimPortForTLSMode(t *testing.T) {
if shimPortForTLSMode("plaintext") == shimPortForTLSMode("starttls") {
t.Fatal("companion: plaintext and starttls must map to DIFFERENT ports (gap-1 fix)")
}
if shimPortForTLSMode("plaintext") != "2526" {
t.Fatalf("plaintext must be 2526, got %q", shimPortForTLSMode("plaintext"))
}
}
// §10: split-From. from_domain_var set → two keys (local + domain); unset → single <local>@<domain>.
func TestSMTPEnv_SplitFrom(t *testing.T) {
m := newMailManager(t, true, "")
meta := &Metadata{
Slug: "nextcloud", DisplayName: "Nextcloud",
SMTPMapping: &SMTPMapping{
HostVar: "SMTP_HOST", PortVar: "SMTP_PORT",
SecurityVar: "SMTP_SECURE", SecurityValue: "",
FromVar: "MAIL_FROM_ADDRESS", FromDomainVar: "MAIL_DOMAIN", FromLocal: "nextcloud",
TLSMode: "plaintext",
},
}
got := envMap(m.smtpEnv(meta, true))
if got["MAIL_FROM_ADDRESS"] != "nextcloud" {
t.Errorf("split From local = %q, want bare 'nextcloud'", got["MAIL_FROM_ADDRESS"])
}
if got["MAIL_DOMAIN"] != "felhom.eu" {
t.Errorf("split From domain = %q, want 'felhom.eu'", got["MAIL_DOMAIN"])
}
if got["SMTP_PORT"] != "2526" {
t.Errorf("nextcloud SMTP_PORT = %q, want 2526 (plaintext)", got["SMTP_PORT"])
}
// Companion: a mapping WITHOUT from_domain_var must produce the single full address (not split).
single := vaultwardenMeta()
gotS := envMap(m.smtpEnv(single, true))
if gotS["SMTP_FROM"] != "vaultwarden@felhom.eu" {
t.Errorf("single-From mapping = %q, want 'vaultwarden@felhom.eu'", gotS["SMTP_FROM"])
}
}
func TestMetadata_SMTPMappingParse(t *testing.T) {
dir := t.TempDir()
yml := `display_name: Vaultwarden
slug: vaultwarden
smtp_mapping:
host_var: SMTP_HOST
port_var: SMTP_PORT
security_var: SMTP_SECURITY
security_value: starttls
from_var: SMTP_FROM
from_name_var: SMTP_FROM_NAME
extra:
SMTP_ACCEPT_INVALID_CERTS: "true"
`
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(yml), 0644); err != nil {
t.Fatal(err)
}
meta := LoadMetadata(dir)
if !meta.HasSMTPMapping() {
t.Fatal("expected smtp_mapping to parse")
}
if meta.SMTPMapping.SecurityValue != "starttls" {
t.Errorf("security_value = %q", meta.SMTPMapping.SecurityValue)
}
if meta.SMTPMapping.Extra["SMTP_ACCEPT_INVALID_CERTS"] != "true" {
t.Errorf("extra not parsed: %v", meta.SMTPMapping.Extra)
}
}
// Tolerant decode: an app with no smtp_mapping parses fine and reports no mapping.
func TestMetadata_NoSMTPMapping(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte("display_name: Plex\nslug: plex\n"), 0644); err != nil {
t.Fatal(err)
}
meta := LoadMetadata(dir)
if meta.HasSMTPMapping() {
t.Fatal("plex must report no smtp_mapping")
}
}