diff --git a/cmd/happyDeliver/main.go b/cmd/happyDeliver/main.go index 3dc3fae..63445d1 100644 --- a/cmd/happyDeliver/main.go +++ b/cmd/happyDeliver/main.go @@ -31,12 +31,12 @@ func main() { fmt.Println("Mail Tester - Email Deliverability Testing Platform") fmt.Println("Version: 0.1.0-dev") - if len(os.Args) < 2 { - printUsage() - os.Exit(1) + cfg, err := config.ConsolidateConfig() + if err != nil { + log.Fatal(err.Error()) } - command := os.Args[1] + command := flag.Arg(0) switch command { case "server": @@ -55,8 +55,10 @@ func main() { } func printUsage() { - fmt.Println("\nUsage:") - fmt.Println(" mailtester server - Start the API server") - fmt.Println(" mailtester analyze - Start the email analyzer (MDA mode)") - fmt.Println(" mailtester version - Print version information") + fmt.Println("\nCommand availables:") + fmt.Println(" happyDeliver server - Start the API server") + fmt.Println(" happyDeliver analyze [-recipient EMAIL] - Analyze email from stdin (MDA mode)") + fmt.Println(" happyDeliver version - Print version information") + fmt.Println("") + flag.Usage() } diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..eee2c4c --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,48 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func declareFlags(o *Config) { + flag.StringVar(&o.DevProxy, "dev", o.DevProxy, "Proxify traffic to this host for static assets") + flag.StringVar(&o.Bind, "bind", o.Bind, "Bind port/socket") + flag.StringVar(&o.Database.Type, "database-type", o.Database.Type, "Select the database type between sqlite, postgres") + flag.StringVar(&o.Database.DSN, "database-dsn", o.Database.DSN, "Database DSN or path") + flag.StringVar(&o.Email.Domain, "domain", o.Email.Domain, "Domain used to receive emails") + flag.StringVar(&o.Email.TestAddressPrefix, "address-prefix", o.Email.TestAddressPrefix, "Expected email adress prefix (deny address that doesn't start with this prefix)") + flag.DurationVar(&o.Analysis.DNSTimeout, "dns-timeout", o.Analysis.DNSTimeout, "Timeout when performing DNS query") + flag.DurationVar(&o.Analysis.HTTPTimeout, "http-timeout", o.Analysis.HTTPTimeout, "Timeout when performing HTTP query") + flag.Var(&StringArray{&o.Analysis.RBLs}, "rbl", "Append a RBL (use this option multiple time to append multiple RBLs)") + + // Others flags are declared in some other files likes sources, storages, ... when they need specials configurations +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func parseCLI(o *Config) error { + flag.Parse() + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a866fbd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,176 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "flag" + "fmt" + "log" + "os" + "path" + "strings" + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Config represents the application configuration +type Config struct { + DevProxy string + Bind string + Database DatabaseConfig + Email EmailConfig + Analysis AnalysisConfig +} + +// DatabaseConfig contains database connection settings +type DatabaseConfig struct { + Type string + DSN string +} + +// EmailConfig contains email domain and routing settings +type EmailConfig struct { + Domain string + TestAddressPrefix string +} + +// AnalysisConfig contains timeout and behavior settings for email analysis +type AnalysisConfig struct { + DNSTimeout time.Duration + HTTPTimeout time.Duration + RBLs []string +} + +// DefaultConfig returns a configuration with sensible defaults +func DefaultConfig() *Config { + return &Config{ + DevProxy: "", + Bind: ":8080", + Database: DatabaseConfig{ + Type: "sqlite", + DSN: "happydeliver.db", + }, + Email: EmailConfig{ + Domain: "happydeliver.local", + TestAddressPrefix: "test-", + }, + Analysis: AnalysisConfig{ + DNSTimeout: 5 * time.Second, + HTTPTimeout: 10 * time.Second, + RBLs: []string{}, + }, + } +} + +// ConsolidateConfig fills an Options struct by reading configuration from +// config files, environment, then command line. +// +// Should be called only one time. +func ConsolidateConfig() (opts *Config, err error) { + // Define defaults options + opts = DefaultConfig() + + declareFlags(opts) + + // Establish a list of possible configuration file locations + configLocations := []string{ + "happydeliver.conf", + } + + if home, err := os.UserConfigDir(); err == nil { + configLocations = append( + configLocations, + path.Join(home, "happydeliver", "happydeliver.conf"), + path.Join(home, "happydomain", "happydeliver.conf"), + ) + } + + configLocations = append(configLocations, path.Join("etc", "happydeliver.conf")) + + // If config file exists, read configuration from it + for _, filename := range configLocations { + if _, e := os.Stat(filename); !os.IsNotExist(e) && !os.IsPermission(e) { + log.Printf("Loading configuration from %s\n", filename) + err = parseFile(opts, filename) + if err != nil { + return + } + break + } + } + + // Then, overwrite that by what is present in the environment + err = parseEnvironmentVariables(opts) + if err != nil { + return + } + + // Finaly, command line takes precedence + err = parseCLI(opts) + if err != nil { + return + } + + return +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Email.Domain == "" { + return fmt.Errorf("email domain cannot be empty") + } + + if _, err := openapi_types.Email(fmt.Sprintf("%s1234-5678-9090@%s", c.Email.TestAddressPrefix, c.Email.Domain)).MarshalJSON(); err != nil { + return fmt.Errorf("invalid email domain: %w", err) + } + + if c.Database.Type != "sqlite" && c.Database.Type != "postgres" { + return fmt.Errorf("unsupported database type: %s", c.Database.Type) + } + + if c.Database.DSN == "" { + return fmt.Errorf("database DSN cannot be empty") + } + + return nil +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func parseLine(o *Config, line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + if len(value) == 0 { + return + } + + key := strings.TrimPrefix(strings.TrimPrefix(orig_key, "HAPPYDELIVER_"), "HAPPYDOMAIN_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/internal/config/custom.go b/internal/config/custom.go new file mode 100644 index 0000000..9461632 --- /dev/null +++ b/internal/config/custom.go @@ -0,0 +1,45 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "fmt" + "strings" +) + +type StringArray struct { + Array *[]string +} + +func (i *StringArray) String() string { + if i.Array == nil { + return "" + } + + return fmt.Sprintf("%v", *i.Array) +} + +func (i *StringArray) Set(value string) error { + *i.Array = append(*i.Array, strings.Split(value, ",")...) + + return nil +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..cd4c344 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,42 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "fmt" + "os" + "strings" +) + +// parseEnvironmentVariables analyzes all the environment variables to find +// each one starting by HAPPYDELIVER_ +func parseEnvironmentVariables(o *Config) (err error) { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "HAPPYDELIVER_") || strings.HasPrefix(line, "HAPPYDOMAIN_") { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..ec28a58 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,54 @@ +// This file is part of the happyDeliver (R) project. +// Copyright (c) 2025 happyDomain +// Authors: Pierre-Olivier Mercier, et al. +// +// This program is offered under a commercial and under the AGPL license. +// For commercial licensing, contact us at . +// +// For AGPL licensing: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// parseFile opens the file at the given filename path, then treat each line +// not starting with '#' as a configuration statement. +func parseFile(o *Config, filename string) error { + fp, err := os.Open(filename) + if err != nil { + return err + } + defer fp.Close() + + scanner := bufio.NewScanner(fp) + n := 0 + for scanner.Scan() { + n += 1 + line := strings.TrimSpace(scanner.Text()) + if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err) + } + } + } + + return nil +}