Compare commits

...

6 commits

Author SHA1 Message Date
e7c7a6bf30
add documentation for 1.0.0
All checks were successful
ci/woodpecker/tag/build-and-deploy/1 Pipeline was successful
ci/woodpecker/tag/build-and-deploy/2 Pipeline was successful
2024-07-26 10:57:39 +02:00
e625620b40
update app logics 2024-07-26 10:57:24 +02:00
e8b4faec3b
Update CI for Makefile 2024-07-26 10:55:24 +02:00
8a965c588a
add config check 2024-07-26 10:54:50 +02:00
d2748259cb
add version flag 2024-07-26 10:54:33 +02:00
6a0644d9af
add makefile 2024-07-26 10:54:02 +02:00
9 changed files with 298 additions and 22 deletions

View file

@ -17,13 +17,14 @@ steps:
- APP_NAME=mailcow-admin-aliases - APP_NAME=mailcow-admin-aliases
- FORGE=https://code.lila.network - FORGE=https://code.lila.network
commands: commands:
- apk add --update --no-cache xz curl jq - apk add --update --no-cache xz curl jq make
- go mod download - go mod download
- go build -o output/$APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM} main.go - make build
- cd output - cd bin
- xz --keep --compress $APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM} - mv $APP_NAME $APP_NAME-${GOOS}-${GOARCH}${GOARM}
- sha256sum $APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM} >> $APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM}.sha256 - xz --keep --compress $APP_NAME-${GOOS}-${GOARCH}${GOARM}
- sha256sum $APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM}.xz >> $APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM}.xz.sha256 - sha256sum $APP_NAME-${GOOS}-${GOARCH}${GOARM} >> $APP_NAME-${GOOS}-${GOARCH}${GOARM}.sha256
- sha256sum $APP_NAME-${GOOS}-${GOARCH}${GOARM}.xz >> $APP_NAME-${GOOS}-${GOARCH}${GOARM}.xz.sha256
- |- - |-
export RELEASE_ID=`curl --location "$FORGE/api/v1/repos/$CI_REPO/releases?limit=10" \ export RELEASE_ID=`curl --location "$FORGE/api/v1/repos/$CI_REPO/releases?limit=10" \
--header 'Accept: application/json' -s -S \ --header 'Accept: application/json' -s -S \
@ -32,23 +33,23 @@ steps:
curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \ curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \
--header "Authorization: token $FORGEJO_APIKEY" \ --header "Authorization: token $FORGEJO_APIKEY" \
--header 'Content-Type: multipart/form-data' -s -S \ --header 'Content-Type: multipart/form-data' -s -S \
--form "attachment=@$APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM};type=application/octet-stream" \ --form "attachment=@$APP_NAME-${GOOS}-${GOARCH}${GOARM};type=application/octet-stream" \
--fail-with-body --fail-with-body
- |- - |-
curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \ curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \
--header "Authorization: token $FORGEJO_APIKEY" \ --header "Authorization: token $FORGEJO_APIKEY" \
--header 'Content-Type: multipart/form-data' -s -S \ --header 'Content-Type: multipart/form-data' -s -S \
--form "attachment=@$APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM}.xz;type=application/octet-stream" \ --form "attachment=@$APP_NAME-${GOOS}-${GOARCH}${GOARM}.xz;type=application/octet-stream" \
--fail-with-body --fail-with-body
- |- - |-
curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \ curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \
--header "Authorization: token $FORGEJO_APIKEY" \ --header "Authorization: token $FORGEJO_APIKEY" \
--header 'Content-Type: multipart/form-data' -s -S \ --header 'Content-Type: multipart/form-data' -s -S \
--form "attachment=@$APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM}.sha256;type=application/octet-stream" \ --form "attachment=@$APP_NAME-${GOOS}-${GOARCH}${GOARM}.sha256;type=application/octet-stream" \
--fail-with-body --fail-with-body
- |- - |-
curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \ curl --location "$FORGE/api/v1/repos/$CI_REPO/releases/$RELEASE_ID/assets" \
--header "Authorization: token $FORGEJO_APIKEY" \ --header "Authorization: token $FORGEJO_APIKEY" \
--header 'Content-Type: multipart/form-data' -s -S \ --header 'Content-Type: multipart/form-data' -s -S \
--form "attachment=@$APP_NAME-${CI_COMMIT_TAG##v}-${GOOS}-${GOARCH}${GOARM}.xz.sha256;type=application/octet-stream" \ --form "attachment=@$APP_NAME-${GOOS}-${GOARCH}${GOARM}.xz.sha256;type=application/octet-stream" \
--fail-with-body --fail-with-body

15
CHANGELOG.md Normal file
View file

@ -0,0 +1,15 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [1.0.0] - 2024-07-26
First release of `mailcow-admin-aliases`
[unreleased]: https://code.lila.network/adoralaura/mailcow-admin-aliases/compare/1.0.0...HEAD
[1.0.0]: https://code.lila.network/adoralaura/mailcow-admin-aliases/releases/tag/1.0.0

17
Makefile Normal file
View file

@ -0,0 +1,17 @@
# Set the default Go build flags
GOFLAGS = -ldflags='-w -s -X main.Version=$(VERSION)'
# Build the application
build:
go build $(GOFLAGS) -o bin/mailcow-admin-aliases cmd/mailcow-admin-aliases/main.go
# Clean the build artifacts
clean:
rm -rf bin
# Run the application
run: build
./bin/mailcow-admin-aliases
# Set a version for the build
VERSION := $(shell git describe --tags --always)

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# mailcow-admin-aliases
![status-badge](https://ci.lila.network/api/badges/24/status.svg)
[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page)
## Introduction
Many mail administrators (me included) want to have some specific email addresses (like postmaster@..., security@..., abuse@..., etc.) always point to a dedicated administrator mailbox, so that e.g. mails regarding abuse or security issues will always be at one central management point.
If you are using [Mailcow](https://docs.mailcow.email/) as your mail server, you'd have to manually add those mail addresses as _aliases_ by hand. I'm also hosting my own Mailcow instance, and by now I have enough (sub)domains that manually adding aliases gets tedious _very quickly_. Thats why I created `mailcow-admin-aliases`.
With `mailcow-admin-aliases` you can (automatically) create the mail aliases you want, e.g. if you configure a new domain. In the [examples](examples/) folder you can find _systemd_ service and timer files to automate the creation of your aliases. `mailcow-admin-aliases` will check which ones already exist and will skip those.
This application is successfully tested with mailcow `2023-03` to `2024-06c`, but it should work with older versions too.
## Quick Start
Installation of the required Mailcow instance is way out of scope of this documentation. For detailed instructions regarding Mailcow, please visit [it's documentation](https://docs.mailcow.email/)
To quickly get started with `mailcow-admin-aliases`, just download the binary from [the mailcow-admin-aliases Release page](https://code.lila.network/adoralaura/mailcow-admin-aliases/releases)...
... fill out the config file...
```shell
vi config.yaml
```
```yaml
---
# Base-Url of your Mailcow instance.
# format: "https://mail.example.com"
api_endpoint:
# Read/Write API Key from your Mailcow instance
# See the [+] API Section at <your-instance>/admin
# format: "XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX"
api_key:
# The mail address the aliases should point to
# Should be a mailbox on your Mailcow instance
# format: "admin-mail@your-domain.com"
admin_email:
# Local part of your important aliases you want created
# Example: if you want security@<your-domains.tld>, one entry should be - security
mail_prefixes:
- abuse
- admin
- postmaster
- security
- webadmin
- webmaster
```
... and run it!
```shell
mailcow-admin-aliases --config /path/to/config.yaml
```
## `mailcow-admin-aliases` CLI flags
when using `mailcow-admin-aliases`, you have the following CLI flag options:
```
-c, --config string Path to config file (default: $WorkDir/config.yaml)
--debug Enable debug logging (beats --quiet)
-d, --dry-run Show what this application *would* do
-q, --quiet Disable logging
-v, --version Show version information
-h, --help Show this overview
```
You can also see the list of CLI options with `--help`
## How it works
`mailcow-admin-aliases` queries the API of the configured Mailcow instance to
- get the currently configured Mailcow domains
- get the list of currently configured aliases
- create missing aliases
## Scaling
As this is a Go application, performance shsould generally be pretty good. **However**, I currently do not have access to a big Mailcow test instance. Should you run into scaling/performance issues, please don't hesitate to contact me!
## Contributing
I use my own [Forgejo Instance](https://code.lila.network) to manage issues and pull requests.
* If you have a trivial fix or improvement, go ahead and create a pull request,
addressing (with `@...`) the maintainer of this repository (see
[MAINTAINERS.md](MAINTAINERS.md)) in the description of the pull request.
* If you plan to do something more involved, first please [send me a mail]( mailto:dev@lauka.net?subject=%5Bmailcow-admin-aliases%5D).
### What to contribute
The best way to help without speaking a lot of Go would be to share your
configuration, alerts, dashboards, and recording rules. If you have something
that works and is not in the repository, please pay it forward and
share what works.
## Changelog
You can find the Changelog here: [Changelog](https://code.lila.network/adoralaura/mailcow-admin-aliases/src/branch/main/CHANGELOG.md)
## License
`mailcow-admin-aliases` is available under the MIT license. See the [LICENSE](https://code.lila.network/adoralaura/mailcow-admin-aliases/src/branch/main/LICENSE) file for more info.

View file

@ -17,14 +17,23 @@ import (
"github.com/spf13/pflag" "github.com/spf13/pflag"
) )
var Version string
func main() { func main() {
quiet := pflag.BoolP("quiet", "q", false, "Disable logging") quietFlag := pflag.BoolP("quiet", "q", false, "Disable logging")
verbose := pflag.Bool("debug", false, "Enable debug logging (beats --quiet)") verboseFlag := pflag.Bool("debug", false, "Enable debug logging (beats --quiet)")
versionFlag := pflag.BoolP("version", "v", false, "Show version information")
pflag.BoolVarP(&configuration.DryRun, "dry-run", "d", false, "Show what this application *would* do") pflag.BoolVarP(&configuration.DryRun, "dry-run", "d", false, "Show what this application *would* do")
pflag.StringVarP(&configuration.ConfigFile, "config", "c", "", "Path to config file (default: $WorkDir/config.yaml)") pflag.StringVarP(&configuration.ConfigFile, "config", "c", "", "Path to config file (default: $WorkDir/config.yaml)")
pflag.Parse() pflag.Parse()
logging.NewSlogLogger(*quiet, *verbose)
if *versionFlag {
fmt.Println("mailcow-admin-aliases " + Version)
return
}
logging.NewSlogLogger(*quietFlag, *verboseFlag)
var cfg configuration.Config var cfg configuration.Config
err := cfg.LoadFromDisk() err := cfg.LoadFromDisk()
@ -45,20 +54,39 @@ func main() {
slog.Error("failed to get aliases", "error", err.Error()) slog.Error("failed to get aliases", "error", err.Error())
os.Exit(1) os.Exit(1)
} }
slog.Info(fmt.Sprintf("Found %v domains, %v aliases.", len(domains), len(existingAliases))) slog.Info(fmt.Sprintf("Found %v existing domains, %v existing aliases", len(domains), len(existingAliases)))
for _, domain := range domains { for _, domain := range domains {
for _, prefix := range cfg.MailPrefixes { for _, prefix := range cfg.MailPrefixes {
address := prefix + "@" + domain.DomainName address := prefix + "@" + domain.DomainName
if address != cfg.AdminEmail && !misc.StringIsInStringSlice(existingAliasesSlice, address) { if address != cfg.AdminEmail && !misc.StringIsInStringSlice(existingAliasesSlice, address) {
slog.Info(fmt.Sprintf("%v: Adding to wanted alias list", address)) slog.Debug(fmt.Sprintf("%v: Adding to wanted alias list", address))
wantedAliases = append(wantedAliases, address) wantedAliases = append(wantedAliases, address)
} else { } else {
slog.Info(fmt.Sprintf("%v: Ignoring alias", address)) slog.Debug(fmt.Sprintf("%v: Ignoring alias", address))
} }
} }
} }
// TODO: create aliases if len(wantedAliases) == 0 {
slog.Info("Found no missing aliases. Nothing to do. Bye")
return
}
slog.Info(fmt.Sprintf("Found %v missing aliases. Will try to create them:", len(wantedAliases)))
for _, alias := range wantedAliases {
if configuration.DryRun {
slog.Info(fmt.Sprintf("[DRYRUN] Creating alias %v", alias))
} else {
mailcowAlias := mailcow.NewAlias(alias, cfg.AdminEmail)
err = mailcowAlias.Create(cfg)
if err != nil {
slog.Error("failed to create alias", "alias", alias, "error", err.Error())
} else {
slog.Info(fmt.Sprintf("Created alias %v", alias))
}
}
}
} }

View file

@ -1,8 +1,20 @@
--- ---
api_endpoint: https://mailcow.example.com # Base-Url of your Mailcow instance.
api_key: aaaaa-bbbbb-ccccc-ddddd-eeeee # format: "https://mail.example.com"
admin_email: admin@example.com api_endpoint:
# Read/Write API Key from your Mailcow instance
# See the [+] API Section at <your-instance>/admin
# format: "XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX"
api_key:
# The mail address the aliases should point to
# Should be a mailbox on your Mailcow instance
# format: "admin-mail@your-domain.com"
admin_email:
# Local part of your important aliases you want created
# Example: if you want security@<your-domains.tld>, one entry should be - security
mail_prefixes: mail_prefixes:
- abuse - abuse
- admin - admin

View file

@ -27,7 +27,7 @@ type Config struct {
MailPrefixes []string `yaml:"mail_prefixes"` MailPrefixes []string `yaml:"mail_prefixes"`
} }
func (c Config) LoadFromDisk() error { func (c *Config) LoadFromDisk() error {
if ConfigFile == "" { if ConfigFile == "" {
slog.Debug("no config file found, looking in working dir") slog.Debug("no config file found, looking in working dir")
wd, err := os.Getwd() wd, err := os.Getwd()
@ -48,8 +48,38 @@ func (c Config) LoadFromDisk() error {
return fmt.Errorf("failed to unmarshal config file: %w", err) return fmt.Errorf("failed to unmarshal config file: %w", err)
} }
c.checkConfig()
slog.Debug("successfully read config file", "path", ConfigFile) slog.Debug("successfully read config file", "path", ConfigFile)
return nil return nil
} }
func (c Config) checkConfig() {
failed := false
if c.ApiEndpoint == "" {
slog.Error("[CONFIG] api_endpoint must not be empty")
failed = true
}
if c.ApiKey == "" {
slog.Error("[CONFIG] api_key must not be empty")
failed = true
}
if c.AdminEmail == "" {
slog.Error("[CONFIG] admin_email must not be empty")
failed = true
}
if len(c.MailPrefixes) < 1 {
slog.Error("[CONFIG] mail_prefixes must have at least one entry")
failed = true
}
if failed {
slog.Error("[CONFIG] Application can't start until above errors are fixed.")
os.Exit(1)
}
}

View file

@ -1,6 +1,7 @@
package mailcow package mailcow
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -18,7 +19,7 @@ type MailcowAlias struct {
PrivateComment string `json:"private_comment"` PrivateComment string `json:"private_comment"`
Goto string `json:"goto"` Goto string `json:"goto"`
Address string `json:"address"` Address string `json:"address"`
IsCatchAll int `json:"is_catch_all"` // skip if 1 IsCatchAll int `json:"is_catch_all"`
Active int `json:"active"` Active int `json:"active"`
ActiveInt int `json:"active_int"` ActiveInt int `json:"active_int"`
SogoVisible int `json:"sogo_visible"` SogoVisible int `json:"sogo_visible"`
@ -32,6 +33,7 @@ func NewAlias(alias string, destination string) MailcowAlias {
a.PrivateComment = "automatically generated by the mail server admin" a.PrivateComment = "automatically generated by the mail server admin"
a.Address = alias a.Address = alias
a.Goto = destination a.Goto = destination
a.SogoVisible = 0
return a return a
} }
@ -78,3 +80,43 @@ func LoadAliases(cfg configuration.Config) ([]MailcowAlias, []string, error) {
return aliases, aliasSlice, nil return aliases, aliasSlice, nil
} }
func (a MailcowAlias) Create(cfg configuration.Config) error {
url := cfg.ApiEndpoint + configuration.AliasAddApiEndpoint
method := "POST"
payload, err := json.Marshal(a)
if err != nil {
return fmt.Errorf("failed to marshal api body: %w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(method, url, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create new POST request: %w", err)
}
slog.Debug("alias create request", "method", method, "url", url)
req.Header.Add("accept", "application/json")
req.Header.Add("Content-Type", "application/json")
req.Header.Add("X-API-Key", cfg.ApiKey)
res, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to POST new alias to server: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
err = checkApiResponseOK(body)
if err != nil {
return fmt.Errorf("api returned an error: %w", err)
}
return nil
}

30
internal/mailcow/api.go Normal file
View file

@ -0,0 +1,30 @@
package mailcow
import (
"encoding/json"
"fmt"
"strings"
)
type MailcowApiResponse struct {
Type string `json:"type"`
Msg []string `json:"msg"`
}
// checkApiResponseOK checks if we got a positive response from the Mailcow API.
//
// Returns an error detailing why the request failed.
func checkApiResponseOK(body []byte) error {
var response []MailcowApiResponse
err := json.Unmarshal(body, &response)
if err != nil {
return fmt.Errorf("failed to unmarshal body: %w", err)
}
if response[0].Type != "success" {
return fmt.Errorf("%v", strings.Join(response[0].Msg, " "))
}
return nil
}