Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/email/constants/constants.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package constants

const (
EnvEmailFrom = "FROM_ADDRESS"
EnvEmailFrom = "FROM_ADDRESS"
EnvEmailProvider = "EMAIL_PROVIDER"

EnvSMTPHost = "SMTP_HOST"
EnvSMTPPort = "SMTP_PORT"
Expand Down
48 changes: 43 additions & 5 deletions plugins/email/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"

"github.com/Authula/authula/models"
"github.com/Authula/authula/plugins/email/constants"
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
135 changes: 135 additions & 0 deletions plugins/email/plugin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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{} },
}
}

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")
}
}
47 changes: 47 additions & 0 deletions plugins/email/types/config.go
Original file line number Diff line number Diff line change
@@ -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"`
}
58 changes: 21 additions & 37 deletions plugins/email/types/types.go
Original file line number Diff line number Diff line change
@@ -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"`
Expand All @@ -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, ", ")
}
Loading
Loading