diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 63e6ebb..b237ac6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,4 +29,20 @@ jobs: - name: Run Vet & Lint run: | go vet . - golint -set_exit_status=1 . \ No newline at end of file + golint -set_exit_status=1 . + + test: + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: '>=1.18.0' + + - name: Install dependencies + run: | + go version + + - name: Test + run: go test ./... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ff9bbe8..ab3d101 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,10 +9,12 @@ jobs: ci: uses: './.github/workflows/ci.yaml' + test: + uses: './.github/workflows/ci.yaml' release: runs-on: ubuntu-22.04 - needs: [ "ci" ] + needs: [ "ci", "test" ] steps: - name: Checkout diff --git a/.gitignore b/.gitignore index 5c4b2ba..a49d0e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ /.idea .env -imap-mailbox-exporter \ No newline at end of file +imap-mailbox-exporter +/config.yaml \ No newline at end of file diff --git a/Makefile b/Makefile index 950f0bc..35cc8d8 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ BINARY=imap-mailbox-exporter build: - go build -o $(BINARY) ./... + go build -o $(BINARY) main.go run: build ./$(BINARY) diff --git a/README.md b/README.md index 9b96c56..e7033b8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,23 @@ http://127.0.0.1:9101/probe?target=INBOX probe_mailbox_count 0 ``` +### Configuration + +The `imap-mailbox-exporter` can be configures with a `config.yaml` file and environment variables. + +```yaml +server: +- hostname: 'hostname' + port: '1234' + accounts: + - username: 'e@mail.com' + password: 'env:E_AT_MAIL_COM_PASSWORD' +``` + +You can use environment variables with the `env:VARIABLE_NAME` directive in YAML. + +The configuration file is expected in `./config.yaml` relative to the `imap-mailbox-exporter` binary. + ### Example Usage You can find a example docker compose configuration. diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..0393395 --- /dev/null +++ b/config/config.go @@ -0,0 +1,89 @@ +package config + +import ( + "errors" + "fmt" + "log" + "os" + "regexp" + "strings" + + "gopkg.in/yaml.v2" +) + +type ConfigAcccount struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type ConfigServer struct { + Host string `yaml:"hostname"` + Port string `yaml:"port"` + + Account []ConfigAcccount `yaml:"accounts"` +} + +func (configServer ConfigServer) HostPort() string { + return configServer.Host + ":" + configServer.Port +} + +type Config struct { + Server []ConfigServer `yaml:"server"` +} + +func NewConfig(path string) (*Config, error) { + config := &Config{} + + configBytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + configString := replaceEnvPlaceholder(string(configBytes)) + + err = yaml.Unmarshal([]byte(configString), &config) + if err != nil { + return nil, err + } + + return config, nil +} + +func replaceEnvPlaceholder(data string) string { + expr := regexp.MustCompile("env:([A-Z_]+)") + matches := expr.FindAll([]byte(data), -1) + + for _, match := range matches { + variable := string(match) + variable = strings.TrimLeft(variable, "env:") + + env := os.Getenv(variable) + if env == "" { + log.Printf("Environment variable %s is empty. Skipping replacement.", variable) + continue + } + + data = strings.ReplaceAll(data, fmt.Sprintf("env:%s", variable), env) + } + + return data +} + +// Find the account and server from the given hostname and username +func (config Config) FindAccountInServer(hostname, username string) (*ConfigServer, *ConfigAcccount, error) { + for _, server := range config.Server { + if server.Host != hostname { + continue + } + + for _, account := range server.Account { + if account.Username != username { + continue + } + + return &server, &account, nil + } + } + + return nil, nil, errors.New("cound not find user on given server in configuration") +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..ae54db2 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,18 @@ +package config + +import ( + "testing" +) + +func TestConfigServerHostPort(t *testing.T) { + server := &ConfigServer{ + Host: "hostname", + Port: "0000", + } + + result := server.HostPort() + if result != "hostname:0000" { + t.Logf("Expected hostname:0000, got %s", result) + t.Fail() + } +} diff --git a/examples/config.yaml b/examples/config.yaml new file mode 100644 index 0000000..5147d7f --- /dev/null +++ b/examples/config.yaml @@ -0,0 +1,6 @@ +server: +- hostname: 'imap.mail.com' + port: '993' + accounts: + - username: 'e@mail.com' + password: 'env:E_MAIL_COM_PW' \ No newline at end of file diff --git a/examples/docker-compose.yaml b/examples/docker-compose.yaml index 0f9c790..8f1c97e 100644 --- a/examples/docker-compose.yaml +++ b/examples/docker-compose.yaml @@ -4,6 +4,8 @@ services: imap-exporter: image: "ghcr.io/jop-software/imap-mailbox-exporter:latest" + volumes: + - ./config.yaml:/config.yaml env_file: - "imap-exporter.env" ports: diff --git a/examples/imap-exporter.env b/examples/imap-exporter.env index 1651b70..3b0dfce 100644 --- a/examples/imap-exporter.env +++ b/examples/imap-exporter.env @@ -1,3 +1 @@ -IMAP_SERVER="" -IMAP_USERNAME="" -IMAP_PASSWORD="" \ No newline at end of file +E_MAIL_COM_PW="my-very-secure-password" \ No newline at end of file diff --git a/go.mod b/go.mod index d3ff307..4edf904 100644 --- a/go.mod +++ b/go.mod @@ -20,4 +20,5 @@ require ( golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect golang.org/x/text v0.3.7 // indirect google.golang.org/protobuf v1.26.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 71e17bb..85dbe1c 100644 --- a/go.sum +++ b/go.sum @@ -460,6 +460,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index cd7c280..407f8fd 100644 --- a/main.go +++ b/main.go @@ -1,48 +1,21 @@ package main import ( - "errors" "fmt" "log" "net/http" - "os" "github.com/emersion/go-imap/client" "github.com/joho/godotenv" + "github.com/jop-software/imap-mailbox-exporter/config" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) -// Config holds the base IMAP credentialsf -type Config struct { - ImapUsername string - ImapPassword string - ImapServer string -} - -func (c *Config) validate() bool { - return c.ImapUsername != "" && c.ImapServer != "" && c.ImapPassword != "" -} - -// NewConfig creates and validated a new Config struct from environment variables -func NewConfig() (*Config, error) { - config := &Config{ - ImapServer: os.Getenv("IMAP_SERVER"), - ImapUsername: os.Getenv("IMAP_USERNAME"), - ImapPassword: os.Getenv("IMAP_PASSWORD"), - } - - if !config.validate() { - return nil, errors.New("not all needed configuration flags could be found") - } - - return config, nil -} - -var config *Config +var cfg *config.Config -func countMailsInMailbox(mailbox string) (uint32, error) { - c, err := client.DialTLS(config.ImapServer, nil) +func countMailsInMailbox(server config.ConfigServer, account config.ConfigAcccount, mailbox string) (uint32, error) { + c, err := client.DialTLS(server.HostPort(), nil) if err != nil { return 0, err } @@ -50,7 +23,7 @@ func countMailsInMailbox(mailbox string) (uint32, error) { defer c.Logout() // Login - if err := c.Login(config.ImapUsername, config.ImapPassword); err != nil { + if err := c.Login(account.Username, account.Password); err != nil { return 0, err } @@ -68,12 +41,12 @@ func main() { _ = godotenv.Load() // Intialize Config - conf, err := NewConfig() + conf, err := config.NewConfig("./config.yaml") if err != nil { log.Fatalf("Could not load configuration: %v", err) } - config = conf + cfg = conf http.HandleFunc("/-/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -93,6 +66,18 @@ func main() { mailbox := target + hostname := r.URL.Query().Get("hostname") + if hostname == "" { + http.Error(w, "Hostname parameter is missing", http.StatusBadRequest) + return + } + + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "Username parameter is missing", http.StatusBadRequest) + return + } + probeCountGauge := prometheus.NewGauge(prometheus.GaugeOpts{ Name: "probe_mailbox_count", Help: "Displays the count of mails found in the mailbox", @@ -101,15 +86,20 @@ func main() { registry := prometheus.NewRegistry() registry.MustRegister(probeCountGauge) + server, account, err := cfg.FindAccountInServer(hostname, username) + if err != nil { + log.Fatalf("Error: %v", err) + } + // TODO: Proper error handling - count, err := countMailsInMailbox(mailbox) + count, err := countMailsInMailbox(*server, *account, mailbox) if err != nil { log.Printf("Cound not load mailbox data: %v", err) http.Error(w, fmt.Sprintf("Cound not load mailbox data: %v", err), http.StatusInternalServerError) return } - log.Printf("Loaded mail count for mailbox %s: %d", mailbox, count) + log.Printf("Load mailbox count for %s of %s on %s: %d", mailbox, username, hostname, count) probeCountGauge.Set(float64(count))