diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..56e496a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +Makefile text eol=lf diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..a82c434 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,23 @@ +changelog: + exclude: + labels: + - chore + categories: + - title: 💥 Breaking Changes + labels: + - 💥 breaking-change + - title: ✨ Exciting New Features + labels: + - ✨ enhancement + - title: 🐞 Bug Fixes + labels: + - 🐞 bug + - title: 🛠️ Dependencies + labels: + - 🛠️ dependencies + - title: 📖 Documentation + labels: + - 📖 docs + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8832633 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,98 @@ +name: CI + +env: + # renovate: github=golangci/golangci-lint + GO_LINT_CI_VERSION: v1.60.3 + +on: + workflow_dispatch: + pull_request: + push: + tags: + - 'v*' + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + name: Build & Test + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + fetch-depth: 0 + + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + + - run: go mod tidy -diff + - run: go build + - run: go test ./... -timeout 20s -race -covermode=atomic -coverprofile=coverage.out -coverpkg=./... + - run: go test ./... -timeout 20s -bench . -benchmem -count 3 + + - uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: go build (with goreleaser) + uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6 + with: + version: '~> v2' + args: release --snapshot + env: + GITHUB_TOKEN: "" + + - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + with: + name: dists + path: dist/ + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + + - name: golangci-lint + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 + with: + version: ${{ env.GO_LINT_CI_VERSION }} + + publish: + name: Publish package + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + needs: + - build + - lint + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + fetch-depth: 0 + - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version-file: 'go.mod' + + - uses: sigstore/cosign-installer@v3.5.0 + + - name: Login to GitHub Container Registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@286f3b13b1b49da4ac219696163fb8c1c93e1200 # v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GORELEASER_CURRENT_TAG: ${{ github.ref_name }} diff --git a/.github/workflows/speeling.yaml b/.github/workflows/speeling.yaml new file mode 100644 index 0000000..88bdc59 --- /dev/null +++ b/.github/workflows/speeling.yaml @@ -0,0 +1,22 @@ +name: Spell checking + +# Trigger on pull requests, and pushes to master branch. +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 + with: + check_filenames: true + # When using this Action in other repos, the --skip option below can be removed + skip: ./.git,./CODE_OF_CONDUCT.md,go.mod,go.sum,go.work,go.work.sum,./internal/ui/assets/i18n diff --git a/.gitignore b/.gitignore index 6f72f89..2535911 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ go.work.sum # env file .env + +local/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..b4142f3 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,63 @@ +linters: + enable-all: true + disable: + - depguard + - exhaustruct + - gomnd + - funlen + - execinquery + - mnd + - exportloopref + - err113 + - tparallel + - paralleltest + +issues: + exclude-rules: + - path: _test\.go + linters: + - lll + - gocognit + - cyclop + - wrapcheck + - maintidx + - dogsled + - contextcheck + - dupword + - dupl + - funlen + - gocyclo + +linters-settings: + sloglint: + no-mixed-args: true + kv-only: false + attr-only: true + no-global: "all" + context: "scope" + static-msg: false + no-raw-keys: false + key-naming-case: snake + forbidden-keys: + - time + - level + - msg + - source + args-on-sep-lines: true + varnamelen: + ignore-names: + - tt + - tc + ignore-decls: + - i int + - a ...any + - err error + - ok bool + - id string + - w http.ResponseWriter + - rt http.RoundTripper + - r *http.Request + - l net.Listener + - t reflect.Type + lll: + line-length: 160 diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..761f005 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,66 @@ +version: 2 + +builds: + - binary: __REPO__ + goos: + - linux + goarch: + - amd64 + - arm64 + mod_timestamp: '{{ .CommitTimestamp }}' + env: + - CGO_ENABLED=0 + flags: + - -trimpath + tags: + - netgo + - static_build + ldflags: + - >- + -s -w + +kos: + - repository: ghcr.io/cloudeteer/azure-communication-gateway-smtp-bridge + tags: + - "{{.Version}}" + - latest + bare: true + base_image: gcr.io/distroless/static-debian12:nonroot # scratch is not supported, see https://github.com/ko-build/ko/pull/1350 + preserve_import_paths: false + creation_time: "{{.CommitTimestamp}}" + ko_data_creation_time: "{{.CommitTimestamp}}" + platforms: + - linux/amd64 + - linux/arm64 + labels: + org.opencontainers.image.created: "{{.Date}}" + org.opencontainers.image.title: "{{.ProjectName}}" + org.opencontainers.image.revision: "{{.FullCommit}}" + org.opencontainers.image.version: "{{.Version}}" + org.opencontainers.image.source: "https://github.com/cloudeteer/azure-communication-gateway-smtp-bridge" + org.opencontainers.image.description: "" +docker_signs: + - artifacts: manifests + output: true + cmd: cosign + env: + - COSIGN_EXPERIMENTAL=1 + args: + - sign + - '--oidc-issuer={{if index .Env "CI"}}https://token.actions.githubusercontent.com{{else}}https://oauth2.sigstore.dev/auth{{end}}' + - '--yes' + - '${artifact}' + +report_sizes: true + +metadata: + mod_timestamp: "{{ .CommitTimestamp }}" + +gomod: + proxy: true + +release: + prerelease: auto + +changelog: + use: github-native diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3ae5a5d --- /dev/null +++ b/Makefile @@ -0,0 +1,105 @@ +## +# Console Colors +## +GREEN := \033[0;32m +YELLOW := \033[0;33m +WHITE := \033[0;37m +CYAN := \033[0;36m +RESET := \033[0m + +# Get the current working directory +CURRENT_DIR := $(CURDIR) + +# Get the directory name of the current working directory +PROJECT_NAME := $(notdir $(CURRENT_DIR)) + +# Get the GOOS value +GOOS := $(shell go env GOOS) + +# Determine the output file extension based on the GOOS value +ifeq ($(GOOS),windows) + EXT := .exe +else + EXT := +endif + +## +# Targets +## +.PHONY: help +help: ## show this help. + @echo "Project: $(PROJECT_NAME)" + @echo 'Usage:' + @echo " ${GREEN}make${RESET} ${YELLOW}${RESET}" + @echo '' + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} { \ + if (/^[a-zA-Z_-]+:.*?##.*$$/) {printf " ${GREEN}%-21s${YELLOW}%s${RESET}\n", $$1, $$2} \ + else if (/^## .*$$/) {printf " ${CYAN}%s${RESET}\n", substr($$1,4)} \ + }' $(MAKEFILE_LIST) | sort + +.PHONY: clean +clean: ## clean builds dir + @rm -rf "$(PROJECT_NAME)" "$(PROJECT_NAME).exe" dist/ + +.PHONY: check +check: test lint golangci ## Run all checks locally + +.PHONY: update +update: ## Run dependency updates + @go get -u ./... + @go mod tidy + @go -C tools get -u + @go -C tools mod tidy + +.PHONY: build ## Build the project +build: clean $(PROJECT_NAME) + +$(PROJECT_NAME): + @go build -o $(PROJECT_NAME)$(EXT) . + +.PHONY: test +test: ## Test the project + @go test -race ./... + +.PHONY: lint +lint: golangci ## Run linter + +.PHONY: fmt ## Format code +fmt: install-tools + @-go fmt ./... + @-tools/bin/gci write . + @-tools/bin/gofumpt -l -w . + @-tools/bin/goimports -l -w . + @-tools/bin/wsl -strict-append -test=true -fix ./... + @-tools/bin/perfsprint -fix ./... + @-tools/bin/godot -w . + @tools/bin/golangci-lint run ./... --fix + +.PHONY: golangci +golangci: + @go run github.com/golangci/golangci-lint/cmd/golangci-lint@${GO_LINT_CI_VERSION} run ./... + +.PHONY: 3rdpartylicenses +3rdpartylicenses: + @go run github.com/google/go-licenses@latest save . --save_path=3rdpartylicenses + +# In order to help reduce toil related to managing tooling for the open telemetry collector +# this section of the makefile looks at only requiring command definitions to be defined +# as part of $(TOOLS_MOD_DIR)/tools.go, following the existing practice. +# Modifying the tools' `go.mod` file will trigger a rebuild of the tools to help +# ensure that all contributors are using the most recent version to make builds repeatable everywhere. +TOOLS_MOD_DIR := tools +TOOLS_MOD_REGEX := "\s+_\s+\".*\"" +TOOLS_PKG_NAMES := $(shell grep -E $(TOOLS_MOD_REGEX) < $(TOOLS_MOD_DIR)/tools.go | tr -d " _\"") +TOOLS_BIN_DIR := bin +TOOLS_BIN_NAMES := $(addprefix $(TOOLS_BIN_DIR)/, $(notdir $(TOOLS_PKG_NAMES))) + +.PHONY: install-tools +install-tools: $(TOOLS_BIN_NAMES) + +$(TOOLS_BIN_DIR): + @mkdir -p $@ + +$(TOOLS_BIN_NAMES): $(TOOLS_BIN_DIR) $(TOOLS_MOD_DIR)/go.mod + go build -C $(TOOLS_MOD_DIR) -o $@ -trimpath $(filter %/$(notdir $@),$(TOOLS_PKG_NAMES)) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b7f912b --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/cloudeteer/azure-communication-gateway-smtp-bridge + +go 1.23 + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.27.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.25.0 // indirect + golang.org/x/text v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cd985f6 --- /dev/null +++ b/go.sum @@ -0,0 +1,54 @@ +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 h1:B/dfvscEQtew9dVuoxqxrUKKv8Ih2f55PydknDamU+g= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0/go.mod h1:fiPSssYvltE08HJchL04dOy+RD4hgrjph0cwGGMntdI= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0 h1:+m0M/LFxN43KvULkDNfdXOgrjtg6UYJPFBJyuEcRCAw= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.0/go.mod h1:PwOyop78lveYMRs6oCxjiVyBdyCgIYH6XHIVZO9/SFQ= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= +github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6 h1:IsMZxCuZqKuao2vNdfD82fjjgPLfyHLpR41Z88viRWs= +github.com/keybase/go-keychain v0.0.0-20231219164618-57a3676c3af6/go.mod h1:3VeWNIJaW+O5xpRQbPp0Ybqu1vJd/pm7s2F473HRrkw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4= +github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= +golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/email/client.go b/internal/email/client.go new file mode 100644 index 0000000..2da9109 --- /dev/null +++ b/internal/email/client.go @@ -0,0 +1,69 @@ +package email + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" +) + +func NewClient(endpoint string, httpClient *http.Client, tokenCredential azcore.TokenCredential) *Client { + return &Client{ + endpoint: endpoint, + tokenCredential: tokenCredential, + httpClient: httpClient, + } +} + +func (client *Client) SendEmail(ctx context.Context, email *Email) error { + postBody, err := json.Marshal(email) + if err != nil { + return fmt.Errorf("failed to marshal email: %w", err) + } + + url := client.endpoint + "/emails:send?api-version=2023-03-31" + bodyBuffer := bytes.NewBuffer(postBody) + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bodyBuffer) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + token, err := client.tokenCredential.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://communication.azure.com/.default"}, + }) + if err != nil { + return fmt.Errorf("failed to get token: %w", err) + } + + request.Header.Add("Content-Type", "application/json") + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := client.httpClient.Do(request) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnauthorized { + commError := ErrorResponse{} + + err = json.NewDecoder(resp.Body).Decode(&commError) + if err != nil { + return fmt.Errorf("failed to decode error response: %w", err) + } + + return fmt.Errorf("status code: %d, error message: %s", resp.StatusCode, commError.Error.Message) + } + + if resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("status code: %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/email/types.go b/internal/email/types.go new file mode 100644 index 0000000..14f2e64 --- /dev/null +++ b/internal/email/types.go @@ -0,0 +1,49 @@ +package email + +import ( + "net/http" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" +) + +type Client struct { + endpoint string + httpClient *http.Client + tokenCredential azcore.TokenCredential +} + +type Email struct { + Recipients Recipients `json:"recipients"` + SenderAddress string `json:"senderAddress"` + Content Content `json:"content"` + Tracking bool `json:"disableUserEngagementTracking"` + Importance string `json:"importance"` + ReplyTo []Address `json:"replyTo"` +} + +type Recipients struct { + To []Address `json:"to"` + CC []Address `json:"cc"` + BCC []Address `json:"bcc"` +} + +type Address struct { + DisplayName string `json:"displayName"` + Address string `json:"address"` +} + +type Content struct { + Subject string `json:"subject"` + HTML string `json:"html"` + PlainText string `json:"plainText"` +} + +type ErrorResponse struct { + Error CommunicationError `json:"error"` +} + +// CommunicationError contains the error code and message. +type CommunicationError struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/internal/smtp/server.go b/internal/smtp/server.go new file mode 100644 index 0000000..1019153 --- /dev/null +++ b/internal/smtp/server.go @@ -0,0 +1,450 @@ +package smtp + +import ( + "bufio" + "errors" + "fmt" + "io" + "log/slog" + "mime" + "mime/multipart" + "net" + "strings" + "sync" + "time" +) + +// MailMessage represents the parsed mail data. +type MailMessage struct { + From string + To string + Subject string + PlainText string + HTMLText string +} + +// CallBackFn defines the callback function type for handling parsed messages. +type CallBackFn func(mail *MailMessage) error + +// Server represents a basic SMTP server. +type Server struct { + address string + callBackFn CallBackFn + + listener net.Listener + logger *slog.Logger + wg sync.WaitGroup + done chan struct{} +} + +// NewServer creates a new SMTP server instance. +func NewServer(address string, logger *slog.Logger, callback CallBackFn) *Server { + return &Server{ + address: address, + callBackFn: callback, + done: make(chan struct{}, 1), + logger: logger, + } +} + +// Start starts the SMTP server and handles incoming connections. +func (s *Server) Start() error { + listener, err := net.Listen("tcp", s.address) + if err != nil { + return fmt.Errorf("error starting SMTP server: %w", err) + } + + s.listener = listener + + for { + conn, err := listener.Accept() + if err != nil { + select { + case <-s.done: + // we called Close() + return nil + default: + } + + if err != nil { + return fmt.Errorf("connection error: %w", err) + } + } + + s.wg.Add(1) + + go func() { + defer s.wg.Done() + + if err := s.handleConnection(conn); err != nil { + s.logger.Error("error handling connection", slog.Any("err", err)) + } + }() + } +} + +// Close shuts down the server. +func (s *Server) Close() error { + if s.listener == nil { + return errors.New("server already closed") + } + + if err := s.listener.Close(); err != nil { + return fmt.Errorf("error closing listener: %w", err) + } + + return nil +} + +func (s *Server) Shutdown() error { + select { + case <-s.done: + return errors.New("server already closed") + default: + close(s.done) + } + + s.wg.Wait() + + return s.Close() +} + +// handleConnection processes an SMTP client connection. +// +//nolint:gocognit +func (s *Server) handleConnection(conn net.Conn) error { + var ( + err error + mailFrom string + mailTo string + ) + + defer func(conn net.Conn) { + _ = conn.Close() + }(conn) + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + + if err = conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + return fmt.Errorf("error setting connection deadline: %w", err) + } + + if _, err = writer.WriteString("220 Welcome to the SMTP server\r\n"); err != nil { + return fmt.Errorf("error writing welcome message: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + for { + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("error reading command: %w", err) + } + + line = strings.TrimSpace(line) + + switch { + case strings.HasPrefix(line, "EHLO"): + // Handle EHLO command + s.handleEHLO(writer) + + case strings.HasPrefix(line, "MAIL FROM"): + // Handle MAIL FROM command + if mailFrom, err = s.parseAddress(line); err != nil { + if _, err = writer.WriteString(fmt.Sprintf("550 Error: %v\r\n", err)); err != nil { + return fmt.Errorf("error writing error message: %w", err) + } + + if err := writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + return nil + } + + if _, err = writer.WriteString("250 OK\r\n"); err != nil { + return fmt.Errorf("error writing OK response: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + case strings.HasPrefix(line, "RCPT TO"): + if mailTo, err = s.parseAddress(line); err != nil { + if _, err = writer.WriteString(fmt.Sprintf("550 Error: %v\r\n", err)); err != nil { + return fmt.Errorf("error writing error message: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + return nil + } + + _, _ = writer.WriteString("250 OK\r\n") + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + case strings.HasPrefix(line, "DATA"): + if _, err = writer.WriteString("354 Start mail input; end with .\r\n"); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + mailData := collectMailData(reader) + if mailData == "" { + if _, err = writer.WriteString("550 Error reading mail data\r\n"); err != nil { + return fmt.Errorf("error writing error message: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + return nil + } + + msg, err := parseMailData(mailData) + if err != nil { + if _, err = writer.WriteString(fmt.Sprintf("550 Error processing mail: %v\r\n", err)); err != nil { + return fmt.Errorf("error writing error message: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + return nil + } + + if msg.From != mailFrom { + msg.From = mailFrom + } + + if msg.To != mailTo { + msg.To = mailTo + } + + // Invoke the callback function + if err := s.callBackFn(msg); err != nil { + _, _ = writer.WriteString(fmt.Sprintf("550 Error processing mail: %v\r\n", err)) + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + return nil + } + + _, _ = writer.WriteString("250 OK\r\n") + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + case strings.HasPrefix(line, "QUIT"): + if _, err = writer.WriteString("221 Bye\r\n"); err != nil { + return fmt.Errorf("error writing QUIT response: %w", err) + } + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + return nil // Close connection after QUIT command + case strings.HasPrefix(line, "NOOP"): + _, _ = writer.WriteString("250 OK\r\n") + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + + case line == ".": + // End of data + break + + default: + _, _ = writer.WriteString("250 OK\r\n") + + if err = writer.Flush(); err != nil { + return fmt.Errorf("error flushing writer: %w", err) + } + } + } +} + +// collectMailData reads the raw mail data from the client until the SMTP end marker ("."). +func collectMailData(reader *bufio.Reader) string { + var mailData strings.Builder + + for { + line, err := reader.ReadString('\n') + if err != nil { + return "" + } + + if strings.TrimSpace(line) == "." { + break + } + + mailData.WriteString(line) + } + + return strings.TrimSpace(mailData.String()) +} + +// parseMailData parses the raw mail data into a MailMessage struct. +func parseMailData(data string) (*MailMessage, error) { + headers, body, err := parseHeadersAndBody(data) + if err != nil { + return nil, err + } + + // Case-insensitive header lookup + getHeader := func(key string) string { + for k, v := range headers { + if strings.EqualFold(k, key) { + return v + } + } + + return "" + } + + mailFrom := getHeader("From") + mailTo := getHeader("To") + subject := getHeader("Subject") + + contentType := getHeader("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + + if err != nil && contentType != "" { + return nil, fmt.Errorf("error parsing Content-Type: %w", err) + } + + mailMessage := &MailMessage{ + From: mailFrom, + To: mailTo, + Subject: subject, + } + + // Handle multipart messages + if strings.HasPrefix(mediaType, "multipart/") { + err = processMultipartMessage(strings.NewReader(body), params["boundary"], mailMessage) + if err != nil { + return nil, err + } + } else { + // Handle simple message (non-multipart) + mailMessage.PlainText = body + } + + return mailMessage, nil +} + +// parseHeadersAndBody splits raw mail data into headers and body. +func parseHeadersAndBody(data string) (map[string]string, string, error) { + parts := strings.SplitN(data, "\r\n\r\n", 2) + if len(parts) < 2 { + return nil, "", errors.New("invalid mail format: missing headers or body") + } + + headers := make(map[string]string) + + for _, line := range strings.Split(parts[0], "\r\n") { + colonIndex := strings.Index(line, ":") + if colonIndex == -1 { + return nil, "", fmt.Errorf("invalid header format: %s", line) + } + + key := strings.TrimSpace(line[:colonIndex]) + value := strings.TrimSpace(line[colonIndex+1:]) + headers[key] = value + } + + body := parts[1] + + return headers, body, nil +} + +// processMultipartMessage processes a multipart message and populates the MailMessage fields. +func processMultipartMessage(bodyReader io.Reader, boundary string, mailMessage *MailMessage) error { + multipartReader := multipart.NewReader(bodyReader, boundary) + + for { + if err := func() error { + part, err := multipartReader.NextPart() + if errors.Is(err, io.EOF) { + return io.EOF + } + + if err != nil { + return fmt.Errorf("error reading multipart message: %w", err) + } + + defer func(part *multipart.Part) { + _ = part.Close() + }(part) + + // Process each part + partContentType := part.Header.Get("Content-Type") + partData, err := io.ReadAll(part) + if err != nil { + return fmt.Errorf("error reading part: %w", err) + } + + if strings.HasPrefix(partContentType, "text/plain") { + mailMessage.PlainText = strings.TrimSpace(string(partData)) + } else if strings.HasPrefix(partContentType, "text/html") { + mailMessage.HTMLText = strings.TrimSpace(string(partData)) + } + + return nil + }(); err != nil { + if errors.Is(err, io.EOF) { + break + } + + return err + } + } + + return nil +} + +// handleEHLO handles the EHLO command and sends a list of supported extensions. +func (s *Server) handleEHLO(writer *bufio.Writer) { + // List of supported SMTP extensions (as per your server configuration) + extensions := []string{ + "250-SIZE 10240000", // 10 MB message size limit + } + + // Send the EHLO response + _, _ = writer.WriteString("250-Hello\r\n") // "250" is the response code for a successful command + for _, ext := range extensions { + _, _ = writer.WriteString(ext + "\r\n") + } + + _, _ = writer.WriteString("250 OK\r\n") // End of EHLO response + _ = writer.Flush() +} + +// parseAddress handles the MAIL FROM AND RCPT TO command. +func (s *Server) parseAddress(line string) (string, error) { + // Extract recipient's email address from the command + parts := strings.SplitN(line, ":", 2) + if len(parts) < 2 { + return "", errors.New("invalid RCPT TO syntax") + } + + address := strings.TrimSpace(parts[1]) + + return strings.Trim(address, "<>"), nil +} diff --git a/internal/smtp/server_test.go b/internal/smtp/server_test.go new file mode 100644 index 0000000..885e3ab --- /dev/null +++ b/internal/smtp/server_test.go @@ -0,0 +1,120 @@ +package smtp_test + +import ( + "fmt" + "log/slog" + "net/smtp" + "os" + "strings" + "testing" + + smtpserver "github.com/cloudeteer/azure-communication-gateway-smtp-bridge/internal/smtp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServer(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + from string + to string + subject string + body string + expectedPlainText string + expectedHTMLText string + }{ + { + name: "test server simple body", + from: "from@example.com", + to: "to@example.com", + subject: "test subject", + body: "test body", + expectedPlainText: "test body", + expectedHTMLText: "", + }, + { + name: "test server multi-part body", + from: "from@example.com", + to: "to@example.com", + subject: "test subject", + body: `MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary="boundary42" + +--boundary42 +Content-Type: text/plain; charset="UTF-8" + +Hi there, +This is a plain text version of the email. +Best regards, +Your Name + +--boundary42 +Content-Type: text/html; charset="UTF-8" + + + +

Hi there,
+ This is an HTML version of the email.
+ Best regards,
+ Your Name +

+ + + +--boundary42--`, + expectedPlainText: `Hi there, +This is a plain text version of the email. +Best regards, +Your Name`, + expectedHTMLText: ` + +

Hi there,
+ This is an HTML version of the email.
+ Best regards,
+ Your Name +

+ +`, + }, + } { + t.Run(test.name, func(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + + // Create a new server + smtpServer := smtpserver.NewServer("localhost:1515", logger, func(mail *smtpserver.MailMessage) error { + assert.Equal(t, test.expectedPlainText, strings.ReplaceAll(mail.PlainText, "\r\n", "\n")) + assert.Equal(t, test.expectedHTMLText, strings.ReplaceAll(mail.HTMLText, "\r\n", "\n")) + assert.Equal(t, test.subject, mail.Subject) + assert.Equal(t, test.from, mail.From) + assert.Equal(t, test.to, mail.To) + + return nil + }) + + errCh := make(chan error, 1) + + go func() { + errCh <- smtpServer.Start() + close(errCh) + }() + + body := test.body + + if !strings.Contains(body, ":") { + body = "\r\n" + body + } + + msg := []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\n"+ + "Subject: %s\r\n"+ + "%s\r\n", test.from, test.to, test.subject, body)) + + err := smtp.SendMail("localhost:1515", nil, test.from, []string{test.to}, msg) + require.NoError(t, err) + + require.NoError(t, smtpServer.Shutdown()) + require.NoError(t, <-errCh) + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..96a0317 --- /dev/null +++ b/main.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/cloudeteer/azure-communication-gateway-smtp-bridge/internal/email" + "github.com/cloudeteer/azure-communication-gateway-smtp-bridge/internal/smtp" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + if err := run(logger); err != nil { + logger.Error(err.Error()) + + os.Exit(1) + } +} + +func run(logger *slog.Logger) error { + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + cred, err := azidentity.NewDefaultAzureCredential(&azidentity.DefaultAzureCredentialOptions{ + ClientOptions: azcore.ClientOptions{ + Transport: httpClient, + }, + }) + if err != nil { + return fmt.Errorf("failed to get default credential: %w", err) + } + + ctx := context.Background() + + _, err = cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://communication.azure.com/.default"}, + }) + if err != nil { + return fmt.Errorf("failed to get token from Azure: %w", err) + } + + connectionString, ok := os.LookupEnv("COMMUNICATION_SERVICES_CONNECTION_STRING") + if !ok { + return errors.New("COMMUNICATION_SERVICES_CONNECTION_STRING is not set") + } + + emailClient := email.NewClient(connectionString, httpClient, cred) + + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + logger.Info("Starting SMTP server at port 1025") + + errCh := make(chan error, 1) + + server := smtp.NewServer(":2525", logger, func(mail *smtp.MailMessage) error { + return emailClient.SendEmail(context.Background(), &email.Email{ + SenderAddress: mail.From, + Recipients: email.Recipients{ + To: []email.Address{ + { + Address: mail.To, + DisplayName: mail.To, + }, + }, + }, + Content: email.Content{ + Subject: mail.Subject, + PlainText: mail.PlainText, + HTML: mail.HTMLText, + }, + }) + }) + + go func() { + if err := server.Start(); err != nil { + errCh <- fmt.Errorf("failed to start SMTP server: %w", err) + } + + close(errCh) + }() + + select { + case <-ctx.Done(): + logger.Info("Shutting down SMTP server") + + err := server.Shutdown() + if err != nil { + return fmt.Errorf("failed to shutdown SMTP server: %w", err) + } + + return nil + case err := <-errCh: + return err + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..efca355 --- /dev/null +++ b/renovate.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "helpers:pinGitHubActionDigestsToSemver" + ], + "labels": [ + "🛠️ dependencies" + ], + "dependencyDashboardApproval": true, + "packageRules": [ + { + "groupName": "GitHub Actions", + "matchManagers": [ + "github-actions" + ] + }, + { + "groupName": "Go tools", + "matchPackagePatterns": [ + "*" + ], + "matchFileNames": [ + "tools/**" + ] + } + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "regexManagers": [ + { + "fileMatch": [ + "(^|/).+\\.yaml$", + "Makefile" + ], + "matchStrings": [ + "\\s*#\\s?renovate: (?.*?)=(?.*?)(\\s+versioning=(?.*?))?\\s+[\\w+\\.\\-]+(?:[:=]|\\s+\\S+)\\s*[\\\"']?(?[\\w+\\.\\-]*)(?:@(?sha256:[a-f0-9]+))?[\\\"']?" + ], + "datasourceTemplate": "{{#if (equals datasource 'github')}}github-tags{{else}}{{{datasource}}}{{/if}}", + "versioningTemplate": "{{#if (equals datasource 'docker')}}docker{{else if versioning}}{{{versioning}}}{{else}}semver{{/if}}" + } + ] +}