From e4aa968132c73ebc94702b7e8e7e2806023ab68b Mon Sep 17 00:00:00 2001 From: Tanvir Ahmed Date: Sat, 12 Sep 2026 20:34:18 +0000 Subject: [PATCH 1/2] chore: Added EMAIL_PROVIDER env var support to the email plugin --- plugins/email/constants/constants.go | 3 +- plugins/email/plugin.go | 48 +++++++++- plugins/email/plugin_test.go | 137 +++++++++++++++++++++++++++ plugins/email/types/config.go | 47 +++++++++ plugins/email/types/types.go | 58 ++++-------- plugins/email/types/types_test.go | 77 +++++++++++++++ 6 files changed, 327 insertions(+), 43 deletions(-) create mode 100644 plugins/email/plugin_test.go create mode 100644 plugins/email/types/config.go create mode 100644 plugins/email/types/types_test.go diff --git a/plugins/email/constants/constants.go b/plugins/email/constants/constants.go index 7aa70970..7ff38c6a 100644 --- a/plugins/email/constants/constants.go +++ b/plugins/email/constants/constants.go @@ -1,7 +1,8 @@ package constants const ( - EnvEmailFrom = "FROM_ADDRESS" + EnvEmailFrom = "FROM_ADDRESS" + EnvEmailProvider = "EMAIL_PROVIDER" EnvSMTPHost = "SMTP_HOST" EnvSMTPPort = "SMTP_PORT" diff --git a/plugins/email/plugin.go b/plugins/email/plugin.go index 932d9abe..518b9a07 100644 --- a/plugins/email/plugin.go +++ b/plugins/email/plugin.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" "github.com/Authula/authula/models" "github.com/Authula/authula/plugins/email/constants" @@ -57,14 +58,18 @@ func (p *EmailPlugin) Init(ctx *models.PluginContext) error { return fmt.Errorf("email plugin requires 'from_address' to be configured in %s env var or config", constants.EnvEmailFrom) } - primaryProvider, err := p.initializeProvider(p.PluginConfig.Provider, true) + if err := p.resolveProviders(); err != nil { + return err + } + + primaryProvider, err := p.initializeProvider(p.PluginConfig.Provider) if err != nil { return err } var fallbackProvider rootservices.MailerService if p.PluginConfig.FallbackProvider != "" && p.PluginConfig.FallbackProvider != p.PluginConfig.Provider { - fallbackProvider, _ = p.initializeProvider(p.PluginConfig.FallbackProvider, false) + fallbackProvider, _ = p.initializeProvider(p.PluginConfig.FallbackProvider) } emailService, err := NewEmailService(p.Logger, p.PluginConfig, primaryProvider, fallbackProvider) @@ -78,9 +83,42 @@ func (p *EmailPlugin) Init(ctx *models.PluginContext) error { return nil } -// initializeProvider creates a provider instance based on the provider type -// isPrimary indicates whether this is a primary provider (returns error) or fallback (logs warning) -func (p *EmailPlugin) initializeProvider(providerType emailtypes.EmailProviderType, isPrimary bool) (rootservices.MailerService, error) { +func (p *EmailPlugin) resolveProviders() error { + rawProvider := strings.TrimSpace(os.Getenv(constants.EnvEmailProvider)) + fromEnv := rawProvider != "" + if !fromEnv { + rawProvider = strings.TrimSpace(p.PluginConfig.Provider.String()) + } + + if rawProvider == "" { + return fmt.Errorf( + "email plugin requires 'provider' to be configured in %s env var or config, supported providers are: %s", + constants.EnvEmailProvider, + emailtypes.SupportedEmailProvidersLabel(), + ) + } + + provider, err := emailtypes.ParseEmailProviderType(rawProvider) + if err != nil { + if fromEnv { + return fmt.Errorf("invalid %s env var: %w", constants.EnvEmailProvider, err) + } + return err + } + p.PluginConfig.Provider = provider + + if rawFallback := strings.TrimSpace(p.PluginConfig.FallbackProvider.String()); rawFallback != "" { + fallback, err := emailtypes.ParseEmailProviderType(rawFallback) + if err != nil { + return fmt.Errorf("invalid fallback email provider: %w", err) + } + p.PluginConfig.FallbackProvider = fallback + } + + return nil +} + +func (p *EmailPlugin) initializeProvider(providerType emailtypes.EmailProviderType) (rootservices.MailerService, error) { var provider rootservices.MailerService var err error diff --git a/plugins/email/plugin_test.go b/plugins/email/plugin_test.go new file mode 100644 index 00000000..6929fe28 --- /dev/null +++ b/plugins/email/plugin_test.go @@ -0,0 +1,137 @@ +package email + +import ( + "strings" + "testing" + + internaltests "github.com/Authula/authula/internal/tests" + "github.com/Authula/authula/models" + "github.com/Authula/authula/plugins/email/constants" + emailtypes "github.com/Authula/authula/plugins/email/types" +) + +func newTestPluginContext() *models.PluginContext { + return &models.PluginContext{ + Logger: &internaltests.MockLogger{}, + ServiceRegistry: &internaltests.TestServiceRegistry{}, + GetConfig: func() *models.Config { return &models.Config{} }, + } +} + +// setProviderCredentials makes both providers initialisable so the tests can +// focus purely on which provider gets selected. +func setProviderCredentials(t *testing.T) { + t.Helper() + t.Setenv(constants.EnvSMTPHost, "smtp.example.com") + t.Setenv(constants.EnvSMTPPort, "587") + t.Setenv(constants.EnvResendApiKey, "re_test_key") +} + +func TestEmailPlugin_Init_ResolvesProvider(t *testing.T) { + tests := []struct { + name string + envProvider string + configProvider emailtypes.EmailProviderType + want emailtypes.EmailProviderType + wantErr bool + errContains string + }{ + { + name: "uses the config provider when the env var is unset", + configProvider: emailtypes.ProviderSMTP, + want: emailtypes.ProviderSMTP, + }, + { + name: "env var overrides the config provider", + envProvider: "resend", + configProvider: emailtypes.ProviderSMTP, + want: emailtypes.ProviderResend, + }, + { + name: "env var is used when no config provider is set", + envProvider: "resend", + want: emailtypes.ProviderResend, + }, + { + name: "env var value is normalised", + envProvider: " SMTP ", + configProvider: emailtypes.ProviderResend, + want: emailtypes.ProviderSMTP, + }, + { + name: "blank env var falls back to the config provider", + envProvider: " ", + configProvider: emailtypes.ProviderResend, + want: emailtypes.ProviderResend, + }, + { + name: "rejects an unsupported env var value", + envProvider: "sendgrid", + configProvider: emailtypes.ProviderSMTP, + wantErr: true, + errContains: constants.EnvEmailProvider, + }, + { + name: "errors when no provider is configured at all", + wantErr: true, + errContains: constants.EnvEmailProvider, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setProviderCredentials(t) + t.Setenv(constants.EnvEmailProvider, tt.envProvider) + + plugin := New(emailtypes.EmailPluginConfig{ + Enabled: true, + Provider: tt.configProvider, + FromAddress: "noreply@example.com", + }) + + err := plugin.Init(newTestPluginContext()) + + if tt.wantErr { + if err == nil { + t.Fatalf("Init() expected an error, got nil (provider = %q)", plugin.PluginConfig.Provider) + } + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("Init() error = %q, want it to mention %q", err, tt.errContains) + } + return + } + + if err != nil { + t.Fatalf("Init() returned unexpected error: %v", err) + } + if plugin.PluginConfig.Provider != tt.want { + t.Errorf("Provider = %q, want %q", plugin.PluginConfig.Provider, tt.want) + } + }) + } +} + +func TestEmailPlugin_Init_NormalisesFallbackProvider(t *testing.T) { + setProviderCredentials(t) + t.Setenv(constants.EnvEmailProvider, "smtp") + + plugin := New(emailtypes.EmailPluginConfig{ + Enabled: true, + Provider: emailtypes.ProviderResend, + FallbackProvider: "SMTP", + FromAddress: "noreply@example.com", + }) + + if err := plugin.Init(newTestPluginContext()); err != nil { + t.Fatalf("Init() returned unexpected error: %v", err) + } + + if plugin.PluginConfig.FallbackProvider != emailtypes.ProviderSMTP { + t.Errorf("FallbackProvider = %q, want %q", plugin.PluginConfig.FallbackProvider, emailtypes.ProviderSMTP) + } + + // The env var promoted SMTP to primary, so it must not also be the fallback. + if plugin.EmailService.fallbackProvider != nil { + t.Error("fallback provider should not be initialised when it matches the primary provider") + } +} diff --git a/plugins/email/types/config.go b/plugins/email/types/config.go new file mode 100644 index 00000000..76c943e5 --- /dev/null +++ b/plugins/email/types/config.go @@ -0,0 +1,47 @@ +package types + +// EmailPluginConfig contains configuration for the email plugin +type EmailPluginConfig struct { + Enabled bool `json:"enabled" toml:"enabled"` + + // Primary provider to use + Provider EmailProviderType `json:"provider" toml:"provider"` + + // Optional fallback provider if primary fails + FallbackProvider EmailProviderType `json:"fallback_provider" toml:"fallback_provider"` + + // FromAddress is the email address to send from + FromAddress string `json:"from_address" toml:"from_address"` + + // TLSMode defines the TLS mode for SMTP provider + TLSMode SMTPTLSMode `json:"tls_mode" toml:"tls_mode"` + + // SMTP configuration + SMTP *SMTPConfig `json:"smtp" toml:"smtp"` + + // Resend configuration + Resend *ResendConfig `json:"resend" toml:"resend"` +} + +type SMTPTLSMode string + +const ( + SMTPTLSModeOff SMTPTLSMode = "off" + SMTPTLSModeStartTLS SMTPTLSMode = "starttls" + SMTPTLSModeTLS SMTPTLSMode = "tls" +) + +func (m SMTPTLSMode) String() string { + return string(m) +} + +type SMTPConfig struct { + Host string `json:"host" toml:"host"` + Port int `json:"port" toml:"port"` + Username string `json:"username" toml:"username"` + Password string `json:"password" toml:"password"` +} + +type ResendConfig struct { + ApiKey string `json:"api_key" toml:"api_key"` +} diff --git a/plugins/email/types/types.go b/plugins/email/types/types.go index 8c2fcdf6..4752aea2 100644 --- a/plugins/email/types/types.go +++ b/plugins/email/types/types.go @@ -1,5 +1,11 @@ package types +import ( + "fmt" + "slices" + "strings" +) + // TemplateData contains all data needed for email template rendering type TemplateData struct { UserName string `json:"user_name"` @@ -21,48 +27,26 @@ func (e EmailProviderType) String() string { return string(e) } -type SMTPTLSMode string - -const ( - SMTPTLSModeOff SMTPTLSMode = "off" - SMTPTLSModeStartTLS SMTPTLSMode = "starttls" - SMTPTLSModeTLS SMTPTLSMode = "tls" -) - -func (m SMTPTLSMode) String() string { - return string(m) +func SupportedEmailProviders() []EmailProviderType { + return []EmailProviderType{ProviderSMTP, ProviderResend} } -// EmailPluginConfig contains configuration for the email plugin -type EmailPluginConfig struct { - Enabled bool `json:"enabled" toml:"enabled"` +func ParseEmailProviderType(raw string) (EmailProviderType, error) { + provider := EmailProviderType(strings.ToLower(strings.TrimSpace(raw))) - // Primary provider to use - Provider EmailProviderType `json:"provider" toml:"provider"` + if slices.Contains(SupportedEmailProviders(), provider) { + return provider, nil + } - // Optional fallback provider if primary fails - FallbackProvider EmailProviderType `json:"fallback_provider" toml:"fallback_provider"` - - // FromAddress is the email address to send from - FromAddress string `json:"from_address" toml:"from_address"` - - // TLSMode defines the TLS mode for SMTP provider - TLSMode SMTPTLSMode `json:"tls_mode" toml:"tls_mode"` - - // SMTP configuration - SMTP *SMTPConfig `json:"smtp" toml:"smtp"` - - // Resend configuration - Resend *ResendConfig `json:"resend" toml:"resend"` + return "", fmt.Errorf("unsupported email provider %q, supported providers are: %s", raw, SupportedEmailProvidersLabel()) } -type SMTPConfig struct { - Host string `json:"host" toml:"host"` - Port int `json:"port" toml:"port"` - Username string `json:"username" toml:"username"` - Password string `json:"password" toml:"password"` -} +func SupportedEmailProvidersLabel() string { + supported := SupportedEmailProviders() + labels := make([]string, 0, len(supported)) + for _, provider := range supported { + labels = append(labels, provider.String()) + } -type ResendConfig struct { - ApiKey string `json:"api_key" toml:"api_key"` + return strings.Join(labels, ", ") } diff --git a/plugins/email/types/types_test.go b/plugins/email/types/types_test.go new file mode 100644 index 00000000..2b18ffa3 --- /dev/null +++ b/plugins/email/types/types_test.go @@ -0,0 +1,77 @@ +package types + +import "testing" + +func TestParseEmailProviderType(t *testing.T) { + tests := []struct { + name string + raw string + want EmailProviderType + wantErr bool + }{ + { + name: "parses smtp provider", + raw: "smtp", + want: ProviderSMTP, + }, + { + name: "parses resend provider", + raw: "resend", + want: ProviderResend, + }, + { + name: "is case insensitive", + raw: "ReSeNd", + want: ProviderResend, + }, + { + name: "trims surrounding whitespace", + raw: " smtp\t", + want: ProviderSMTP, + }, + { + name: "rejects an unsupported provider", + raw: "sendgrid", + wantErr: true, + }, + { + name: "rejects an empty value", + raw: " ", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseEmailProviderType(tt.raw) + + if tt.wantErr { + if err == nil { + t.Fatalf("ParseEmailProviderType(%q) expected an error, got provider %q", tt.raw, got) + } + return + } + + if err != nil { + t.Fatalf("ParseEmailProviderType(%q) returned unexpected error: %v", tt.raw, err) + } + if got != tt.want { + t.Errorf("ParseEmailProviderType(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestSupportedEmailProviders(t *testing.T) { + providers := SupportedEmailProviders() + + if len(providers) != 2 { + t.Fatalf("got %d supported providers, want 2", len(providers)) + } + + for _, provider := range providers { + if _, err := ParseEmailProviderType(provider.String()); err != nil { + t.Errorf("supported provider %q is not parseable: %v", provider, err) + } + } +} From c472bf9fcb866d879696e45620a4e0f5a14c5ac8 Mon Sep 17 00:00:00 2001 From: Tanvir Ahmed Date: Sat, 12 Sep 2026 20:38:32 +0000 Subject: [PATCH 2/2] chore: Removed comment --- plugins/email/plugin_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/plugins/email/plugin_test.go b/plugins/email/plugin_test.go index 6929fe28..0e0f800e 100644 --- a/plugins/email/plugin_test.go +++ b/plugins/email/plugin_test.go @@ -18,8 +18,6 @@ func newTestPluginContext() *models.PluginContext { } } -// setProviderCredentials makes both providers initialisable so the tests can -// focus purely on which provider gets selected. func setProviderCredentials(t *testing.T) { t.Helper() t.Setenv(constants.EnvSMTPHost, "smtp.example.com")