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>
This commit is contained in:
@@ -47,11 +47,20 @@ func (m *Manager) smtpEnv(meta *Metadata, appEmailEnabled bool) []string {
|
||||
if security == "" {
|
||||
security = "starttls"
|
||||
}
|
||||
port := shimPortForTLSMode(sm.TLSMode)
|
||||
|
||||
out := []string{
|
||||
fmt.Sprintf("%s=%s", sm.HostVar, host),
|
||||
fmt.Sprintf("%s=%s", sm.PortVar, "2525"),
|
||||
fmt.Sprintf("%s=%s@%s", sm.FromVar, local, fromDomain),
|
||||
fmt.Sprintf("%s=%s", sm.PortVar, port),
|
||||
}
|
||||
// From: single full-address var (default) or split local-part + domain (nextcloud-style).
|
||||
if sm.FromDomainVar != "" {
|
||||
out = append(out,
|
||||
fmt.Sprintf("%s=%s", sm.FromVar, local),
|
||||
fmt.Sprintf("%s=%s", sm.FromDomainVar, fromDomain),
|
||||
)
|
||||
} else {
|
||||
out = append(out, fmt.Sprintf("%s=%s@%s", sm.FromVar, local, fromDomain))
|
||||
}
|
||||
if sm.SecurityVar != "" {
|
||||
out = append(out, fmt.Sprintf("%s=%s", sm.SecurityVar, security))
|
||||
@@ -67,6 +76,20 @@ func (m *Manager) smtpEnv(meta *Metadata, appEmailEnabled bool) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// shimPortForTLSMode maps a mapping's tls_mode to the shim listener port. Default (empty/"starttls")
|
||||
// keeps existing apps on :2525 (plaintext + STARTTLS); "plaintext" → :2526 (STARTTLS NOT advertised,
|
||||
// for opportunistic-upgrade clients with no cert-skip); "implicit-tls" → :2465.
|
||||
func shimPortForTLSMode(mode string) string {
|
||||
switch mode {
|
||||
case "plaintext":
|
||||
return "2526"
|
||||
case "implicit-tls":
|
||||
return "2465"
|
||||
default: // "" or "starttls"
|
||||
return "2525"
|
||||
}
|
||||
}
|
||||
|
||||
// fromDisplayName picks the From display name: the household-set name (settings) wins,
|
||||
// else the app's display name.
|
||||
func (m *Manager) fromDisplayName(meta *Metadata) string {
|
||||
|
||||
@@ -143,6 +143,61 @@ func TestSMTPEnv_MealieMapping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// §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
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
|
||||
// Metadata holds app information parsed from .felhom.yml.
|
||||
type Metadata struct {
|
||||
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
// OpenPath is appended to the app's public URL for the "Megnyitás" (open) link, for apps whose UI
|
||||
// isn't at "/" (e.g. Gokapi → "/admin"). Empty = bare root. Must start with "/".
|
||||
OpenPath string `yaml:"open_path,omitempty" json:"open_path,omitempty"`
|
||||
@@ -50,6 +50,16 @@ type SMTPMapping struct {
|
||||
FromNameVar string `yaml:"from_name_var" json:"from_name_var"` // env key for the From display name (optional)
|
||||
FromLocal string `yaml:"from_local" json:"from_local"` // From local-part (defaults to the app slug)
|
||||
Extra map[string]string `yaml:"extra" json:"extra"` // fixed extra env (accept-invalid-cert flags, etc.)
|
||||
// TLSMode selects which shim listener the app is pointed at (which fixes the port):
|
||||
// "" / "starttls" → :2525 (plaintext + STARTTLS advertised) — the default; existing apps unchanged.
|
||||
// "plaintext" → :2526 (plaintext, STARTTLS NOT advertised) — for clients that opportunistically
|
||||
// upgrade to STARTTLS and can't skip cert verification (cal.com, nextcloud).
|
||||
// "implicit-tls" → :2465 (whole connection TLS).
|
||||
TLSMode string `yaml:"tls_mode" json:"tls_mode"`
|
||||
// FromDomainVar: for apps that SPLIT the From into local-part + domain env vars (nextcloud:
|
||||
// MAIL_FROM_ADDRESS + MAIL_DOMAIN). When set, the controller injects FromVar=<local> and
|
||||
// FromDomainVar=<allowlisted-domain> separately instead of FromVar=<local>@<domain>.
|
||||
FromDomainVar string `yaml:"from_domain_var" json:"from_domain_var"`
|
||||
}
|
||||
|
||||
// HasSMTPMapping reports whether this app declares a usable email mapping.
|
||||
@@ -61,8 +71,8 @@ func (m *Metadata) HasSMTPMapping() bool {
|
||||
// credential from a file inside the running container. The file path is catalog-defined (trusted).
|
||||
// Verification stays catalog-driven so any future self-seeding app can reuse the mechanism.
|
||||
type InitialCredentials struct {
|
||||
File string `yaml:"file" json:"file"` // path INSIDE the container
|
||||
Format string `yaml:"format" json:"format"` // "json" | "regex" | "plain"
|
||||
File string `yaml:"file" json:"file"` // path INSIDE the container
|
||||
Format string `yaml:"format" json:"format"` // "json" | "regex" | "plain"
|
||||
// Container overrides which container to read from; empty → the stack's main container.
|
||||
Container string `yaml:"container,omitempty" json:"container,omitempty"`
|
||||
// json format: which keys hold the username/password (password_key required; username optional).
|
||||
|
||||
Reference in New Issue
Block a user