├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── container.yml │ ├── integration.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── cmd └── email │ └── main.go ├── go.mod ├── go.sum ├── mage.go ├── magefile.go ├── pkg ├── entrypoint │ └── entrypoint.go └── envutil │ └── inputprocessors.go └── version └── version.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | # Check for updates to GitHub Actions every weekday 8 | interval: "daily" 9 | - package-ecosystem: "gomod" 10 | directory: "/" 11 | schedule: 12 | # Check for updates to GitHub Actions every weekday 13 | interval: "weekly" -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '23 7 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'go' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v3 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v3 73 | -------------------------------------------------------------------------------- /.github/workflows/container.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Container Build 3 | on: 4 | workflow_run: 5 | workflows: 6 | - Build and Test 7 | types: 8 | - completed 9 | branches: 10 | - main 11 | push: 12 | tags: 13 | - v* 14 | 15 | env: 16 | REGISTRY: ghcr.io 17 | IMAGE_NAME: ${{ github.repository }} 18 | 19 | jobs: 20 | container-build: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | packages: write 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Fetch tags 30 | run: git fetch --prune --unshallow --tags -f 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | - name: Set up Docker Buildx 36 | id: buildx 37 | uses: docker/setup-buildx-action@v2 38 | 39 | - name: Log in to the Container registry 40 | uses: docker/login-action@v3 41 | with: 42 | registry: ${{ env.REGISTRY }} 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Extract metadata (tags, labels) for Docker 47 | id: meta 48 | uses: docker/metadata-action@v5 49 | with: 50 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 51 | tags: | 52 | # set latest tag for default branch 53 | type=raw,value=latest,enable={{is_default_branch}} 54 | type=schedule 55 | type=semver,pattern={{version}} 56 | type=semver,pattern={{major}}.{{minor}} 57 | type=semver,pattern={{major}} 58 | type=ref,event=branch 59 | type=ref,event=pr 60 | type=sha 61 | type=sha,format=long 62 | 63 | - name: Build and push Docker image 64 | uses: docker/build-push-action@v6 65 | with: 66 | context: . 67 | platforms: linux/amd64,linux/arm64 68 | push: true 69 | tags: ${{ steps.meta.outputs.tags }} 70 | labels: ${{ steps.meta.outputs.labels }} 71 | 72 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build and Test 3 | "on": 4 | push: 5 | branches: 6 | - "*" 7 | tags: 8 | - v* 9 | pull_request: 10 | branches: 11 | - "*" 12 | workflow_call: 13 | jobs: 14 | style: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | - name: Setup Go 20 | uses: actions/setup-go@v4 21 | with: 22 | go-version: 1.23 23 | - name: Check style 24 | run: go run mage.go style 25 | 26 | # lint: 27 | # runs-on: ubuntu-latest 28 | # steps: 29 | # - name: Checkout 30 | # uses: actions/checkout@v4 31 | # - name: Setup Go 32 | # uses: actions/setup-go@v4 33 | # with: 34 | # go-version: 1.23 35 | # - name: Lint 36 | # run: go run mage.go lint 37 | 38 | test: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout 42 | uses: actions/checkout@v4 43 | - name: Setup Go 44 | uses: actions/setup-go@v4 45 | with: 46 | go-version: 1.23 47 | - name: Test 48 | run: go run mage.go test 49 | - name: Merge Coverage Files 50 | run: go run mage.go coverage 51 | - name: Coveralls 52 | uses: shogo82148/actions-goveralls@v1 53 | with: 54 | path-to-profile: .cover.out 55 | 56 | build: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Checkout 60 | uses: actions/checkout@v4 61 | - name: Setup Go 62 | uses: actions/setup-go@v4 63 | with: 64 | go-version: 1.23 65 | - name: Build 66 | run: go run mage.go binary 67 | 68 | # deploy: 69 | # runs-on: ubuntu-latest 70 | # needs: 71 | # - build 72 | # steps: 73 | # - name: Checkout 74 | # uses: actions/checkout@v4 75 | # with: 76 | # fetch-depth: 0 77 | # - name: Setup Go 78 | # uses: actions/setup-go@v2 79 | # - name: Build Release 80 | # run: make -j$(cat /proc/cpuinfo | grep processor | wc -l) release 81 | # - name: Release 82 | # uses: softprops/action-gh-release@v1 83 | # if: startsWith(github.ref, 'refs/tags/r') 84 | # env: 85 | # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 86 | # with: 87 | # files: | 88 | # callback-linux-arm.tar.gz 89 | # callback-linux-arm64.tar.gz 90 | # callback-linux-x86_64.tar.gz 91 | # callback-linux-i386.tar.gz 92 | # callback-windows-i386.zip 93 | # callback-windows-x86_64.zip 94 | # callback-darwin-x86_64.tar.gz 95 | # callback-darwin-arm64.tar.gz 96 | # callback-freebsd-x86_64.tar.gz -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | integration: 10 | uses: ./.github/workflows/integration.yml 11 | 12 | generate-release-matrix: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | release-matrix: ${{ steps.generate-matrix.outputs.release-matrix }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: 1.23 24 | 25 | - name: Generate Release Matrix 26 | id: generate-matrix 27 | run: go run mage.go githubReleaseMatrix 28 | 29 | release-build: 30 | runs-on: ubuntu-latest 31 | needs: 32 | - integration 33 | - generate-release-matrix 34 | strategy: 35 | matrix: 36 | osarch: ${{ fromJson(needs.generate-release-matrix.outputs.release-matrix) }} 37 | max-parallel: 10 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v4 41 | 42 | - name: Fetch tags 43 | run: git fetch --prune --unshallow --tags -f 44 | 45 | - name: Setup Go 46 | uses: actions/setup-go@v4 47 | with: 48 | go-version: 1.23 49 | 50 | - name: Release Build 51 | run: go run mage.go release ${{ matrix.osarch }} 52 | 53 | - uses: actions/upload-artifact@v3 54 | with: 55 | name: release 56 | path: release/* 57 | 58 | release: 59 | runs-on: ubuntu-latest 60 | needs: 61 | - release-build 62 | steps: 63 | - name: Download artifacts 64 | uses: actions/download-artifact@v3 65 | with: 66 | name: release 67 | path: release 68 | 69 | - name: Release 70 | uses: softprops/action-gh-release@v1 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | with: 74 | files: | 75 | release/* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /email 2 | .idea 3 | node_modules 4 | /bin 5 | /.bin 6 | /.coverage 7 | /release 8 | /.cover.out 9 | /test-configs 10 | /.junit 11 | /image.iid 12 | /..bfg-report 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.23 AS build 2 | 3 | RUN mkdir /build 4 | 5 | WORKDIR /build 6 | 7 | COPY ./ ./ 8 | 9 | RUN go run mage.go binary 10 | 11 | RUN useradd -u 1001 app \ 12 | && mkdir /config \ 13 | && chown app:root /config 14 | 15 | FROM scratch 16 | 17 | COPY --from=build /build/email /bin/email 18 | COPY --from=build /etc/passwd /etc/passwd 19 | COPY --from=build /config / 20 | 21 | ENV PATH=/bin:$PATH 22 | 23 | ENTRYPOINT ["email"] 24 | 25 | USER 1001 26 | 27 | CMD [] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Will Rouesnel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build and Test](https://github.com/wrouesnel/emailcli/actions/workflows/integration.yml/badge.svg)](https://github.com/wrouesnel/emailcli/actions/workflows/integration.yml) 2 | [![Release](https://github.com/wrouesnel/emailcli/actions/workflows/release.yml/badge.svg)](https://github.com/wrouesnel/emailcli/actions/workflows/release.yml) 3 | [![Container Build](https://github.com/wrouesnel/emailcli/actions/workflows/container.yml/badge.svg)](https://github.com/wrouesnel/emailcli/actions/workflows/container.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/wrouesnel/emailcli/badge.svg?branch=main)](https://coveralls.io/github/wrouesnel/emailcli?branch=main) 5 | 6 | 7 | # Emailcli 8 | 9 | Because surprisingly, everything else out there just barely fails to 10 | be useful to me. 11 | 12 | This utility does exactly one thing: wrap a Golang email library in a 13 | command line interface. 14 | 15 | ## Install 16 | 17 | Download a release binary from the [releases](https://github.com/wrouesnel/emailcli/releases/latest) page or use the container packaging: 18 | 19 | podman run -it --rm ghcr.io/wrouesnel/emailcli:latest 20 | 21 | OR 22 | 23 | docker run -it --rm ghcr.io/wrouesnel/emailcli:latest 24 | 25 | ## Usage 26 | 27 | ``` 28 | email --username test@gmail.com --password somepassword \ 29 | --host smtp.gmail.com --port 587 \ 30 | --subject "Test mail" \ 31 | --body "Test Body" test@gmail.com 32 | ``` 33 | 34 | For security, it also supports reading settings from environment 35 | variables: 36 | ``` 37 | export EMAIL_PASSWORD=somepassword 38 | email --username test@gmail.com \ 39 | --host smtp.gmail.com --port 587 \ 40 | --subject "Test mail" \ 41 | --body "Test Body" test@gmail.com 42 | ``` 43 | 44 | All command line variables can be used as environment variables by 45 | appending EMAIL_ to the parameter name and capitalizing. 46 | -------------------------------------------------------------------------------- /cmd/email/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/wrouesnel/emailcli/pkg/entrypoint" 7 | "github.com/wrouesnel/emailcli/pkg/envutil" 8 | 9 | "github.com/samber/lo" 10 | ) 11 | 12 | func main() { 13 | env := lo.Must(envutil.FromEnvironment(os.Environ())) 14 | 15 | args := entrypoint.LaunchArgs{ 16 | StdIn: os.Stdin, 17 | StdOut: os.Stdout, 18 | StdErr: os.Stderr, 19 | Env: env, 20 | Args: os.Args[1:], 21 | } 22 | ret := entrypoint.Entrypoint(args) 23 | os.Exit(ret) 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wrouesnel/emailcli 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.9.0 7 | github.com/integralist/go-findroot v0.0.0-20160518114804-ac90681525dc 8 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible 9 | github.com/magefile/mage v1.15.0 10 | github.com/mholt/archiver v3.1.1+incompatible 11 | github.com/pkg/errors v0.9.1 12 | github.com/rogpeppe/go-internal v1.12.0 13 | github.com/samber/lo v1.47.0 14 | go.uber.org/zap v1.27.0 15 | gopkg.in/yaml.v3 v3.0.1 16 | ) 17 | 18 | require ( 19 | github.com/dsnet/compress v0.0.1 // indirect 20 | github.com/frankban/quicktest v1.14.6 // indirect 21 | github.com/golang/snappy v0.0.4 // indirect 22 | github.com/nwaples/rardecode v1.1.3 // indirect 23 | github.com/pierrec/lz4 v2.6.1+incompatible // indirect 24 | github.com/ulikunitz/xz v0.5.12 // indirect 25 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect 26 | go.uber.org/multierr v1.10.0 // indirect 27 | golang.org/x/mod v0.17.0 // indirect 28 | golang.org/x/text v0.16.0 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= 2 | github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= 4 | github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/dsnet/compress v0.0.1 h1:PlZu0n3Tuv04TzpfPbrnI0HW/YwodEXDS+oPKahKF0Q= 11 | github.com/dsnet/compress v0.0.1/go.mod h1:Aw8dCMJ7RioblQeTqt88akK31OvO8Dhf5JflhBbQEHo= 12 | github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= 13 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 14 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 15 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 16 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 17 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 18 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 19 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 20 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 21 | github.com/integralist/go-findroot v0.0.0-20160518114804-ac90681525dc h1:4IZpk3M4m6ypx0IlRoEyEyY1gAdicWLMQ0NcG/gBnnA= 22 | github.com/integralist/go-findroot v0.0.0-20160518114804-ac90681525dc/go.mod h1:UlaC6ndby46IJz9m/03cZPKKkR9ykeIVBBDE3UDBdJk= 23 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= 24 | github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= 25 | github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= 26 | github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= 27 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 28 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 29 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 30 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 31 | github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= 32 | github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 33 | github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= 34 | github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= 35 | github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc= 36 | github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= 37 | github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= 38 | github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= 39 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 40 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 41 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 42 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 43 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 44 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 45 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 46 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 47 | github.com/samber/lo v1.47.0 h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= 48 | github.com/samber/lo v1.47.0/go.mod h1:RmDH9Ct32Qy3gduHQuKJ3gW1fMHAnE/fAzQuf6He5cU= 49 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 50 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 51 | github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= 52 | github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= 53 | github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 54 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= 55 | github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= 56 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 57 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 58 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 59 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 60 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 61 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 62 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 63 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 64 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 65 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 66 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 67 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 68 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 69 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 70 | -------------------------------------------------------------------------------- /mage.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | 8 | "github.com/magefile/mage/mage" 9 | ) 10 | 11 | func main() { os.Exit(mage.Main()) } 12 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | 3 | // Self-contained go-project magefile. 4 | 5 | //nolint:deadcode,gochecknoglobals,gochecknoinits,wrapcheck,varnamelen,gomnd,forcetypeassert,forbidigo,funlen,gocognit,cyclop,nolintlint 6 | package main 7 | 8 | import ( 9 | "bufio" 10 | "bytes" 11 | "encoding/json" 12 | "fmt" 13 | "io/ioutil" 14 | "math/bits" 15 | "os" 16 | "os/exec" 17 | "path" 18 | "path/filepath" 19 | "runtime" 20 | "sort" 21 | "strconv" 22 | "strings" 23 | "sync" 24 | "time" 25 | 26 | "gopkg.in/yaml.v3" 27 | 28 | archiver "github.com/mholt/archiver" 29 | 30 | "github.com/magefile/mage/mg" 31 | "github.com/magefile/mage/sh" 32 | "github.com/magefile/mage/target" 33 | 34 | "github.com/integralist/go-findroot/find" 35 | "github.com/pkg/errors" 36 | 37 | "github.com/rogpeppe/go-internal/modfile" 38 | "github.com/samber/lo" 39 | ) 40 | 41 | var ( 42 | errAutogenMultipleScriptSections = errors.New("found multiple managed script sections") 43 | errAutogenUnknownPreCommitScriptFormat = errors.New("unknown pre-commit script format") 44 | errPlatformNotSupported = errors.New("current platform is not supported") 45 | errParallelBuildFailed = errors.New("parallel build failed") 46 | errLintBisect = errors.New("error during lint bisect") 47 | ) 48 | 49 | var curDir = func() string { 50 | name, _ := os.Getwd() 51 | return name 52 | }() 53 | 54 | const ( 55 | constCoverageDir = ".coverage" 56 | constToolBinDir = ".bin" 57 | constGitHookDir = "githooks" 58 | constBinDir = "bin" 59 | constReleaseDir = "release" 60 | constCmdDir = "cmd" 61 | constCoverFile = ".cover.out" 62 | constJunitDir = ".junit" 63 | ) 64 | 65 | const ( 66 | constManagedScriptSectionHead = "## ++ BUILD SYSTEM MANAGED - DO NOT EDIT ++ ##" 67 | constManagedScriptSectionFoot = "## -- BUILD SYSTEM MANAGED - DO NOT EDIT -- ##" 68 | ) 69 | 70 | // normalizePath turns a path into an absolute path and removes symlinks. 71 | func normalizePath(name string) string { 72 | absPath := must(filepath.Abs(name)) 73 | return absPath 74 | } 75 | 76 | // binRootName is set to the name of the directory by default. 77 | var binRootName = func() string { 78 | return must(find.Repo()).Path 79 | }() 80 | 81 | // dockerImageName is set to the name of the directory by default. 82 | var dockerImageName = func() string { 83 | return binRootName 84 | }() 85 | 86 | var coverageDir = normalizePath(path.Join(curDir, constCoverageDir)) 87 | var toolsBinDir = normalizePath(path.Join(curDir, constToolBinDir)) 88 | var gitHookDir = normalizePath(path.Join(curDir, constGitHookDir)) 89 | var binDir = normalizePath(path.Join(curDir, constBinDir)) 90 | var releaseDir = normalizePath(path.Join(curDir, constReleaseDir)) 91 | var cmdDir = normalizePath(path.Join(curDir, constCmdDir)) 92 | var junitDir = normalizePath(path.Join(curDir, constJunitDir)) 93 | 94 | var outputDirs = []string{binDir, releaseDir, coverageDir, junitDir} 95 | 96 | //nolint:unused,varcheck 97 | var containerName = func() string { 98 | if name := os.Getenv("CONTAINER_NAME"); name != "" { 99 | return name 100 | } 101 | return dockerImageName 102 | }() 103 | 104 | type Platform struct { 105 | OS string 106 | Arch string 107 | BinSuffix string 108 | } 109 | 110 | func (p *Platform) String() string { 111 | return fmt.Sprintf("%s-%s", p.OS, p.Arch) 112 | } 113 | 114 | func (p *Platform) PlatformDir() string { 115 | platformDir := path.Join(binDir, fmt.Sprintf("%s_%s_%s", productName, versionShort, p.String())) 116 | return platformDir 117 | } 118 | 119 | func (p *Platform) PlatformBin(cmd string) string { 120 | platformBin := fmt.Sprintf("%s%s", cmd, p.BinSuffix) 121 | return path.Join(p.PlatformDir(), platformBin) 122 | } 123 | 124 | func (p *Platform) ArchiveDir() string { 125 | return fmt.Sprintf("%s_%s_%s", productName, versionShort, p.String()) 126 | } 127 | 128 | func (p *Platform) ReleaseBase() string { 129 | return path.Join(releaseDir, fmt.Sprintf("%s_%s_%s", productName, versionShort, p.String())) 130 | } 131 | 132 | // platforms is the list of platforms we build for. 133 | var platforms []Platform = []Platform{ 134 | {"linux", "arm", ""}, 135 | {"linux", "arm64", ""}, 136 | {"linux", "amd64", ""}, 137 | {"linux", "386", ""}, 138 | {"darwin", "amd64", ""}, 139 | {"darwin", "arm64", ""}, 140 | {"windows", "amd64", ".exe"}, 141 | {"windows", "386", ".exe"}, 142 | {"freebsd", "amd64", ""}, 143 | } 144 | 145 | // platformsLookup is the lookup map of the above list by /. 146 | var platformsLookup map[string]Platform = func() map[string]Platform { 147 | ret := make(map[string]Platform, len(platforms)) 148 | for _, platform := range platforms { 149 | ret[platform.String()] = platform 150 | } 151 | return ret 152 | }() 153 | 154 | // productName can be overridden by environ product name. 155 | var productName = func() string { 156 | if name := os.Getenv("PRODUCT_NAME"); name != "" { 157 | return name 158 | } 159 | name, _ := os.Getwd() 160 | return path.Base(name) 161 | }() 162 | 163 | // Source files. 164 | var goSrc []string 165 | var goDirs []string 166 | var goPkgs []string 167 | var goCmds []string 168 | 169 | // Function to calculate the version symbol 170 | var versionSymbol = func() string { 171 | gomodBytes := lo.Must(ioutil.ReadFile("go.mod")) 172 | parsedGoMod := lo.Must(modfile.ParseLax("go.mod", gomodBytes, nil)) 173 | return fmt.Sprintf("%s/version.Version", parsedGoMod.Module.Mod.Path) 174 | } 175 | 176 | var version = func() string { 177 | if v := os.Getenv("VERSION"); v != "" { 178 | return v 179 | } 180 | out, _ := sh.Output("git", "describe", "--dirty") 181 | 182 | if out == "" { 183 | // Try and at least describe the git commit. 184 | out, _ = sh.Output("git", "describe", "--dirty", "--always") 185 | if out != "" { 186 | return fmt.Sprintf("v0.0.0-0-%s", out) 187 | } 188 | return "v0.0.0" 189 | } 190 | 191 | return out 192 | }() 193 | 194 | var versionShort = func() string { 195 | if v := os.Getenv("VERSION_SHORT"); v != "" { 196 | return v 197 | } 198 | out, _ := sh.Output("git", "describe", "--abbrev=0") 199 | 200 | if out == "" { 201 | return "v0.0.0" 202 | } 203 | 204 | return out 205 | }() 206 | 207 | var concurrency = func() int { 208 | if v := os.Getenv("CONCURRENCY"); v != "" { 209 | pv, err := strconv.ParseUint(v, 10, bits.UintSize) 210 | if err != nil { 211 | panic(err) 212 | } 213 | 214 | // Ensure we always have at least 1 process 215 | if int(pv) < 1 { 216 | return 1 217 | } 218 | 219 | return int(pv) 220 | } 221 | return runtime.NumCPU() 222 | }() 223 | 224 | var linterDeadline = func() time.Duration { 225 | if v := os.Getenv("LINTER_DEADLINE"); v != "" { 226 | d, _ := time.ParseDuration(v) 227 | if d != 0 { 228 | return d 229 | } 230 | } 231 | return time.Minute 232 | }() 233 | 234 | func Log(args ...interface{}) { 235 | if mg.Verbose() { 236 | fmt.Println(args...) 237 | } 238 | } 239 | 240 | var concurrencyQueue chan struct{} 241 | var concurrencyWg *sync.WaitGroup 242 | 243 | // concurrentRun calls a function while respecting concurrency limits, and 244 | // returns a promise-like function which will resolve to the value. 245 | func concurrentRun[T any](fn func() T) func() T { 246 | concurrencyQueue <- struct{}{} // Acquire a job 247 | concurrencyWg.Add(1) // Ensure we can wait 248 | 249 | rCh := make(chan T) 250 | go func() { 251 | rCh <- fn() 252 | <-concurrencyQueue // Release a job 253 | concurrencyWg.Done() // Mark job finished 254 | }() 255 | 256 | return func() T { 257 | return <-rCh 258 | } 259 | } 260 | 261 | // waitResults accepts a map of operations and waits for them all to complete. 262 | func waitResults(m map[string]func() error) func() error { 263 | type dispatch struct { 264 | k string 265 | v error 266 | } 267 | 268 | resultQueue := make(chan dispatch, len(m)) 269 | for k, fn := range m { 270 | go func(k string, fn func() error) { 271 | resultQueue <- dispatch{ 272 | k: k, 273 | v: fn(), 274 | } 275 | }(k, fn) 276 | } 277 | 278 | return func() error { 279 | buildError := false 280 | 281 | for i := 0; i < len(m); i++ { 282 | result := <-resultQueue 283 | if result.v != nil { 284 | buildError = true 285 | fmt.Printf("Error: %s: %s\n", result.k, result.v) 286 | } 287 | } 288 | 289 | if buildError { 290 | return errParallelBuildFailed 291 | } 292 | return nil 293 | } 294 | } 295 | 296 | func init() { 297 | // Initialize concurrency queue 298 | concurrencyQueue = make(chan struct{}, concurrency) 299 | concurrencyWg = &sync.WaitGroup{} 300 | 301 | // Set environment 302 | os.Setenv("PATH", fmt.Sprintf("%s:%s", toolsBinDir, os.Getenv("PATH"))) 303 | os.Setenv("GOBIN", toolsBinDir) 304 | Log("Build PATH: ", os.Getenv("PATH")) 305 | Log("Concurrency:", concurrency) 306 | goSrc = func() []string { 307 | results := new([]string) 308 | err := filepath.Walk(".", func(relpath string, info os.FileInfo, _ error) error { 309 | // Ensure absolute path so globs work 310 | path, err := filepath.Abs(relpath) 311 | if err != nil { 312 | panic(err) 313 | } 314 | 315 | // Look for files 316 | if info.IsDir() { 317 | return nil 318 | } 319 | 320 | // Exclusions 321 | for _, exclusion := range []string{toolsBinDir, binDir, releaseDir, coverageDir} { 322 | if strings.HasPrefix(path, exclusion) { 323 | if info.IsDir() { 324 | return filepath.SkipDir 325 | } 326 | return nil 327 | } 328 | } 329 | 330 | if strings.Contains(path, "/vendor/") { 331 | if info.IsDir() { 332 | return filepath.SkipDir 333 | } 334 | return nil 335 | } 336 | 337 | if strings.Contains(path, ".git") { 338 | if info.IsDir() { 339 | return filepath.SkipDir 340 | } 341 | return nil 342 | } 343 | 344 | if !strings.HasSuffix(path, ".go") { 345 | return nil 346 | } 347 | 348 | *results = append(*results, path) 349 | return nil 350 | }) 351 | if err != nil { 352 | panic(err) 353 | } 354 | return *results 355 | }() 356 | goDirs = func() []string { 357 | resultMap := make(map[string]struct{}) 358 | for _, path := range goSrc { 359 | absDir, err := filepath.Abs(filepath.Dir(path)) 360 | if err != nil { 361 | panic(err) 362 | } 363 | resultMap[absDir] = struct{}{} 364 | } 365 | results := []string{} 366 | for k := range resultMap { 367 | results = append(results, k) 368 | } 369 | return results 370 | }() 371 | goPkgs = func() []string { 372 | results := []string{} 373 | out, err := sh.Output("go", "list", "./...") 374 | if err != nil { 375 | panic(err) 376 | } 377 | for _, line := range strings.Split(out, "\n") { 378 | if !strings.Contains(line, "/vendor/") { 379 | results = append(results, line) 380 | } 381 | } 382 | return results 383 | }() 384 | goCmds = func() []string { 385 | results := []string{} 386 | 387 | finfos, err := ioutil.ReadDir(cmdDir) 388 | if err != nil { 389 | panic(err) 390 | } 391 | for _, finfo := range finfos { 392 | results = append(results, finfo.Name()) 393 | } 394 | return results 395 | }() 396 | 397 | // Ensure output dirs exist 398 | for _, dir := range outputDirs { 399 | panicOnError(os.MkdirAll(dir, os.FileMode(0777))) 400 | } 401 | } 402 | 403 | // must consumes an error from a function. 404 | func must[T any](result T, err error) T { 405 | if err != nil { 406 | panic(err) 407 | } 408 | return result 409 | } 410 | 411 | func panicOnError(err error) { 412 | if err != nil { 413 | panic(err) 414 | } 415 | } 416 | 417 | // concurrencyLimitedBuild executes a certain number of commands limited by concurrency. 418 | func concurrencyLimitedBuild(buildCmds ...interface{}) error { 419 | resultsCh := make(chan error, len(buildCmds)) 420 | concurrencyControl := make(chan struct{}, concurrency) 421 | for _, buildCmd := range buildCmds { 422 | go func(buildCmd interface{}) { 423 | concurrencyControl <- struct{}{} 424 | resultsCh <- buildCmd.(func() error)() 425 | <-concurrencyControl 426 | }(buildCmd) 427 | } 428 | // Doesn't work at the moment 429 | // mg.Deps(buildCmds...) 430 | results := []error{} 431 | var resultErr error = nil 432 | for len(results) < len(buildCmds) { 433 | err := <-resultsCh 434 | results = append(results, err) 435 | if err != nil { 436 | fmt.Println(err) 437 | resultErr = errors.Wrap(errParallelBuildFailed, "concurrencyLimitedBuild command failure") 438 | } 439 | fmt.Printf("Finished %v of %v\n", len(results), len(buildCmds)) 440 | } 441 | 442 | return resultErr 443 | } 444 | 445 | // Tools builds build tools of the project and is depended on by all other build targets. 446 | func Tools() (err error) { 447 | // Catch panics and convert to errors 448 | defer func() { 449 | if perr := recover(); perr != nil { 450 | err = perr.(error) 451 | } 452 | }() 453 | 454 | toolBuild := func(toolType string, tools ...[]string) error { 455 | toolTargets := []interface{}{} 456 | for _, toolImport := range tools { 457 | binName := toolImport[0] 458 | localToolImport := toolImport[1] 459 | 460 | if binName != "" { 461 | if _, err := os.Stat(path.Join(toolsBinDir, binName)); err == nil { 462 | // Skip named binary which we already have 463 | continue 464 | } 465 | } 466 | 467 | f := func() error { 468 | return sh.Run("go", "install", "-v", localToolImport) 469 | } 470 | toolTargets = append(toolTargets, f) 471 | } 472 | 473 | Log("Build", toolType, "tools") 474 | if berr := concurrencyLimitedBuild(toolTargets...); berr != nil { 475 | return berr 476 | } 477 | return nil 478 | } 479 | 480 | // golangci-lint don't want to support if it's not a binary release, so 481 | // don't go-install. 482 | if berr := toolBuild("static", []string{"", "github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.2"}, 483 | []string{"gocovmerge", "github.com/wadey/gocovmerge@latest"}); berr != nil { 484 | return berr 485 | } 486 | 487 | return nil 488 | } 489 | 490 | func lintArgs(args ...string) []string { 491 | returnedArgs := []string{"-j", fmt.Sprintf("%v", concurrency), fmt.Sprintf( 492 | "--timeout=%s", linterDeadline.String())} 493 | returnedArgs = append(returnedArgs, args...) 494 | return returnedArgs 495 | } 496 | 497 | // Lint runs gometalinter for code quality. CI will run this before accepting PRs. 498 | func Lint() error { 499 | mg.Deps(Tools) 500 | extraArgs := lintArgs("run") 501 | extraArgs = append(extraArgs, goDirs...) 502 | return sh.RunV("golangci-lint", extraArgs...) 503 | } 504 | 505 | // LintBisect runs the linters one directory at a time. 506 | // It is useful for finding problems where golangci-lint won't compile and 507 | // doesn't emit an error message. 508 | func LintBisect() error { 509 | errs := []error{} 510 | for _, goDir := range goDirs { 511 | fmt.Println("Linting:", goDir) 512 | err := sh.RunV("golangci-lint", lintArgs("run", goDir)...) 513 | if err != nil { 514 | fmt.Println("LINT ERROR IN:", goDir) 515 | errs = append(errs, err) 516 | } 517 | } 518 | if len(errs) > 0 { 519 | return errLintBisect 520 | } 521 | return nil 522 | } 523 | 524 | // listLinters gets the golangci-lint config 525 | func listLinters() ([]string, error) { 526 | cmd := exec.Command("golangci-lint", "linters") 527 | output, err := cmd.Output() 528 | if err != nil { 529 | return []string{}, errors.Wrap(err, "golangci-lint linters failed to run") 530 | } 531 | bio := bufio.NewReader(bytes.NewBuffer(output)) 532 | linters := []string{} 533 | for { 534 | line, _ := bio.ReadString('\n') 535 | line = strings.Trim(line, " \n\t") 536 | if line == "" { 537 | continue 538 | } 539 | if line == "Enabled by your configuration linters:" { 540 | continue 541 | } 542 | if line == "Disabled by your configuration linters:" { 543 | // Done 544 | break 545 | } 546 | linter := strings.Split(line, ":")[0] 547 | linter = strings.Split(linter, "(")[0] 548 | linter = strings.Trim(linter, " \n\t") 549 | linters = append(linters, linter) 550 | } 551 | return linters, nil 552 | } 553 | 554 | // LintersBisect runs all linters in golangci-lint one at a time. 555 | // It is useful find broken linters. 556 | func LintersBisect() error { 557 | linters, err := listLinters() 558 | if err != nil { 559 | return errors.Wrap(err, "LintersBisect: listLinters failed") 560 | } 561 | errs := map[string]error{} 562 | 563 | // Annoyingly, we have to override .golangci.yml to allow us to pick linters 564 | // one by one. 565 | golangCi := make(map[string]interface{}) 566 | _ = yaml.Unmarshal(must(ioutil.ReadFile(".golangci.yml")), golangCi) 567 | delete(golangCi, "linters") 568 | tempConfig, err := ioutil.TempFile("", ".golangci.*.yml") 569 | if err != nil { 570 | return errors.Wrap(err, "LintersBisect: TempFile failed") 571 | } 572 | _ = must(tempConfig.Write(must(yaml.Marshal(golangCi)))) 573 | defer os.Remove(tempConfig.Name()) 574 | 575 | for _, linter := range linters { 576 | extraArgs := lintArgs("run", 577 | fmt.Sprintf("--config=%s", tempConfig.Name()), 578 | "--disable-all", 579 | fmt.Sprintf("--enable=%s", linter)) 580 | extraArgs = append(extraArgs, goDirs...) 581 | err := sh.RunV("golangci-lint", extraArgs...) 582 | if err != nil { 583 | errs[linter] = err 584 | } 585 | } 586 | 587 | if len(errs) > 0 { 588 | for linter := range errs { 589 | fmt.Println("FAILED LINTER:", linter) 590 | } 591 | 592 | return errLintBisect 593 | } 594 | return nil 595 | } 596 | 597 | // fmt runs golangci-lint with the formatter options. 598 | func formattingLinter(doFixes bool) error { 599 | mg.Deps(Tools) 600 | args := []string{"run", "--no-config", "--disable-all", "--enable=gofmt", "--enable=goimports", "--enable=godot"} 601 | if doFixes { 602 | args = append(args, "--fix") 603 | } 604 | return sh.RunV("golangci-lint", args...) 605 | } 606 | 607 | // Style checks formatting of the file. CI will run this before acceptiing PRs. 608 | func Style() error { 609 | return formattingLinter(false) 610 | } 611 | 612 | // Fmt automatically formats all source code files. 613 | func Fmt() error { 614 | return formattingLinter(true) 615 | } 616 | 617 | func listCoverageFiles() ([]string, error) { 618 | result := []string{} 619 | finfos, derr := ioutil.ReadDir(coverageDir) 620 | if derr != nil { 621 | return result, derr 622 | } 623 | for _, finfo := range finfos { 624 | result = append(result, path.Join(coverageDir, finfo.Name())) 625 | } 626 | return result, nil 627 | } 628 | 629 | // Test run test suite. 630 | func Test() error { 631 | mg.Deps(Tools) 632 | 633 | // Ensure coverage directory exists 634 | if err := os.MkdirAll(coverageDir, os.FileMode(0777)); err != nil { 635 | return err 636 | } 637 | 638 | // Clean up coverage directory 639 | coverFiles, derr := listCoverageFiles() 640 | if derr != nil { 641 | return derr 642 | } 643 | for _, coverFile := range coverFiles { 644 | if err := sh.Rm(coverFile); err != nil { 645 | return err 646 | } 647 | } 648 | 649 | // Run tests 650 | //coverProfiles := []string{} 651 | for _, pkg := range goPkgs { 652 | coverProfile := path.Join(coverageDir, 653 | fmt.Sprintf("%s%s", strings.ReplaceAll(pkg, "/", "-"), ".out")) 654 | testErr := sh.Run("go", "test", "-v", "-covermode", "count", fmt.Sprintf("-coverprofile=%s", coverProfile), 655 | pkg) 656 | if testErr != nil { 657 | return testErr 658 | } 659 | //coverProfiles = append(coverProfiles, coverProfile) 660 | } 661 | 662 | return nil 663 | } 664 | 665 | // Coverage sums up the coverage profiles in .coverage. It does not clean up after itself or before. 666 | func Coverage() error { 667 | // Clean up coverage directory 668 | coverFiles, derr := listCoverageFiles() 669 | if derr != nil { 670 | return derr 671 | } 672 | 673 | mergedCoverage, err := sh.Output("gocovmerge", coverFiles...) 674 | if err != nil { 675 | return err 676 | } 677 | return ioutil.WriteFile(constCoverFile, []byte(mergedCoverage), os.FileMode(0777)) 678 | } 679 | 680 | // All runs a full suite suitable for CI 681 | // 682 | //nolint:unparam 683 | func All() error { 684 | mg.SerialDeps(Style, Lint, Test, Coverage, Release) 685 | return nil 686 | } 687 | 688 | // GithubReleaseMatrix emits a line to setup build matrix jobs for release builds. 689 | // 690 | //nolint:unparam 691 | func GithubReleaseMatrix() error { 692 | output := make([]string, 0, len(platforms)) 693 | for _, platform := range platforms { 694 | output = append(output, platform.String()) 695 | } 696 | jsonData := must(json.Marshal(output)) 697 | fmt.Printf("::set-output name=release-matrix::%s\n", string(jsonData)) 698 | return nil 699 | } 700 | 701 | func makeBuilder(cmd string, platform Platform) func() error { 702 | f := func() error { 703 | cmdSrc := fmt.Sprintf("./%s/%s", must(filepath.Rel(curDir, cmdDir)), cmd) 704 | 705 | Log("Make platform binary directory:", platform.PlatformDir()) 706 | if err := os.MkdirAll(platform.PlatformDir(), os.FileMode(0777)); err != nil { 707 | return errors.Wrapf(err, "error making directory: cmd: %s", cmd) 708 | } 709 | 710 | Log("Checking for changes:", platform.PlatformBin(cmd)) 711 | if changed, err := target.Path(platform.PlatformBin(cmd), goSrc...); !changed { 712 | if err != nil { 713 | if !os.IsNotExist(err) { 714 | return errors.Wrapf(err, "error while checking for changes: cmd: %s", cmd) 715 | } 716 | } else { 717 | return nil 718 | } 719 | } 720 | 721 | fmt.Println("Building", platform.PlatformBin(cmd)) 722 | return sh.RunWith(map[string]string{"CGO_ENABLED": "0", "GOOS": platform.OS, "GOARCH": platform.Arch}, 723 | "go", "build", "-a", "-ldflags", fmt.Sprintf("-buildid='' -extldflags '-static' -X %s=%s", versionSymbol(), version), 724 | "-trimpath", "-o", platform.PlatformBin(cmd), cmdSrc) 725 | } 726 | return f 727 | } 728 | 729 | func getCurrentPlatform() *Platform { 730 | var curPlatform *Platform 731 | for _, p := range platforms { 732 | if p.OS == runtime.GOOS && p.Arch == runtime.GOARCH { 733 | storedP := p 734 | curPlatform = &storedP 735 | } 736 | } 737 | Log("Determined current platform:", curPlatform) 738 | return curPlatform 739 | } 740 | 741 | // Binary build a binary for the current platform. 742 | func Binary() error { 743 | curPlatform := getCurrentPlatform() 744 | if curPlatform == nil { 745 | return errPlatformNotSupported 746 | } 747 | 748 | if err := ReleaseBin(curPlatform.String()); err != nil { 749 | return err 750 | } 751 | 752 | buildResults := map[string]func() error{} 753 | 754 | for _, cmd := range goCmds { 755 | buildResults[cmd] = concurrentRun(func() error { 756 | // Make a root symlink to the build 757 | cmdPath := path.Join(curDir, cmd) 758 | os.Remove(cmdPath) 759 | if err := os.Symlink(curPlatform.PlatformBin(cmd), cmdPath); err != nil { 760 | return err 761 | } 762 | return nil 763 | }) 764 | } 765 | 766 | return waitResults(buildResults)() 767 | } 768 | 769 | // doReleaseBin handles the deferred building of an actual release binary. 770 | // 771 | //nolint:gocritic 772 | func doReleaseBin(OSArch string) func() error { 773 | platform, ok := platformsLookup[OSArch] 774 | if !ok { 775 | return func() error { return errors.Wrapf(errPlatformNotSupported, "ReleaseBin: %s", OSArch) } 776 | } 777 | 778 | buildResults := map[string]func() error{} 779 | 780 | for _, cmd := range goCmds { 781 | buildResults[cmd] = concurrentRun(makeBuilder(cmd, platform)) 782 | } 783 | 784 | return waitResults(buildResults) 785 | } 786 | 787 | // ReleaseBin builds cross-platform release binaries under the bin/ directory. 788 | // 789 | //nolint:gocritic 790 | func ReleaseBin(OSArch string) error { 791 | return doReleaseBin(OSArch)() 792 | } 793 | 794 | // ReleaseBinAll builds cross-platform release binaries under the bin/ directory. 795 | func ReleaseBinAll() error { 796 | buildResults := map[string]func() error{} 797 | for OSArch := range platformsLookup { 798 | buildResults[fmt.Sprintf("build-%s", OSArch)] = doReleaseBin(OSArch) 799 | } 800 | 801 | return waitResults(buildResults)() 802 | } 803 | 804 | // Release builds release archives under the release/ directory. 805 | // 806 | //nolint:gocritic 807 | func doRelease(OSArch string) func() error { 808 | platform, ok := platformsLookup[OSArch] 809 | if !ok { 810 | return func() error { return errors.Wrapf(errPlatformNotSupported, "ReleaseBin: %s", OSArch) } 811 | } 812 | 813 | return func() error { 814 | if err := ReleaseBin(OSArch); err != nil { 815 | return err 816 | } 817 | 818 | archiveCmds := map[string]func() error{} 819 | if platform.OS == "windows" { 820 | // build a zip binary as well 821 | archiveName := fmt.Sprintf("%s.zip", platform.ReleaseBase()) 822 | archiveCmds[archiveName] = concurrentRun(func() error { 823 | if _, err := os.Stat(archiveName); err == nil { 824 | _ = os.Remove(archiveName) 825 | } 826 | archiveDir := path.Join(binDir, platform.ArchiveDir()) 827 | fmt.Println("Archiving", archiveName) 828 | return archiver.NewZip().Archive([]string{archiveDir}, archiveName) 829 | }) 830 | } 831 | 832 | // build tar gz 833 | archiveName := fmt.Sprintf("%s.tar.gz", platform.ReleaseBase()) 834 | archiveCmds[archiveName] = concurrentRun(func() error { 835 | if _, err := os.Stat(archiveName); err == nil { 836 | _ = os.Remove(archiveName) 837 | } 838 | archiveDir := path.Join(binDir, platform.ArchiveDir()) 839 | fmt.Println("Archiving", archiveName) 840 | return archiver.NewTarGz().Archive([]string{archiveDir}, archiveName) 841 | }) 842 | 843 | return waitResults(archiveCmds)() 844 | } 845 | } 846 | 847 | // PlatformTargets prints the list of target platforms 848 | func PlatformTargets() error { 849 | platforms := make([]string, 0, len(platformsLookup)) 850 | for platform := range platformsLookup { 851 | platforms = append(platforms, platform) 852 | } 853 | sort.Strings(platforms) 854 | for _, platform := range platforms { 855 | fmt.Println(platform) 856 | } 857 | return nil 858 | } 859 | 860 | // Release a binary archive for a specific platform 861 | // 862 | //nolint:gocritic 863 | func Release(OSArch string) error { 864 | return doRelease(OSArch)() 865 | } 866 | 867 | // Release builds release archives under the release/ directory. 868 | func ReleaseAll() error { 869 | buildResults := map[string]func() error{} 870 | for OSArch := range platformsLookup { 871 | buildResults[fmt.Sprintf("release-%s", OSArch)] = doRelease(OSArch) 872 | } 873 | 874 | return waitResults(buildResults)() 875 | } 876 | 877 | // Clean deletes build output and cleans up the working directory. 878 | func Clean() error { 879 | for _, name := range goCmds { 880 | if err := sh.Rm(path.Join(binDir, name)); err != nil { 881 | return err 882 | } 883 | } 884 | 885 | for _, name := range outputDirs { 886 | if err := sh.Rm(name); err != nil { 887 | return err 888 | } 889 | } 890 | return nil 891 | } 892 | 893 | // Debug prints the value of internal state variables 894 | // 895 | //nolint:unparam 896 | func Debug() error { 897 | fmt.Println("Source Files:", goSrc) 898 | fmt.Println("Packages:", goPkgs) 899 | fmt.Println("Directories:", goDirs) 900 | fmt.Println("Command Paths:", goCmds) 901 | fmt.Println("Output Dirs:", outputDirs) 902 | fmt.Println("PATH:", os.Getenv("PATH")) 903 | 904 | fmt.Println("Version:", version) 905 | fmt.Println("Version short:", versionShort) 906 | fmt.Println("Version symbol:", versionSymbol()) 907 | return nil 908 | } 909 | 910 | // Autogen configure local git repository with commit hooks. 911 | func Autogen() error { 912 | fmt.Println("Installing git hooks in local repository...") 913 | 914 | for _, fname := range must(ioutil.ReadDir(gitHookDir)) { 915 | hookName := fname.Name() 916 | if !fname.IsDir() { 917 | continue 918 | } 919 | 920 | gitHookPath := fmt.Sprintf(".git/hooks/%s", hookName) 921 | repoHookPath := path.Join(gitHookDir, fname.Name()) 922 | 923 | scripts := []string{} 924 | for _, scriptName := range must(ioutil.ReadDir(repoHookPath)) { 925 | if scriptName.IsDir() { 926 | continue 927 | } 928 | fullHookPath := path.Join(gitHookDir, hookName, scriptName.Name()) 929 | relHookPath := must(filepath.Rel(binRootName, fullHookPath)) 930 | 931 | scripts = append(scripts, relHookPath) 932 | 933 | data, err := ioutil.ReadFile(gitHookPath) 934 | if err != nil { 935 | data = []byte("#!/bin/bash\n") 936 | } 937 | 938 | splitHook := strings.Split(string(data), "\n") 939 | if strings.TrimRight(splitHook[0], " \t") != "#!/bin/bash" { 940 | fmt.Printf("Don't know how to update your %s script.\n", hookName) 941 | return errAutogenUnknownPreCommitScriptFormat 942 | } 943 | 944 | headAt := -1 945 | tailAt := -1 946 | for idx, line := range splitHook { 947 | // Search until header. 948 | if strings.TrimPrefix(line, " ") == constManagedScriptSectionHead { 949 | if headAt != -1 { 950 | fmt.Println("Found multiple managed script sections in ", fname.Name(), "first was at line ", headAt, "second was at line ", idx) 951 | return errAutogenMultipleScriptSections 952 | } 953 | headAt = idx 954 | continue 955 | } else if strings.TrimPrefix(line, " ") == constManagedScriptSectionFoot { 956 | if tailAt != -1 { 957 | fmt.Println("Found multiple managed script sections in ", fname.Name(), "first was at line ", headAt, "second was at line ", idx) 958 | return errAutogenMultipleScriptSections 959 | } 960 | tailAt = idx + 1 961 | continue 962 | } 963 | } 964 | 965 | if headAt == -1 { 966 | headAt = 1 967 | } 968 | 969 | if tailAt == -1 { 970 | tailAt = len(splitHook) 971 | } 972 | 973 | scriptPackage := []string{constManagedScriptSectionHead} 974 | scriptPackage = append(scriptPackage, "# These lines were added by go run mage.go autogen.", "") 975 | for _, scriptPath := range scripts { 976 | scriptPackage = append(scriptPackage, fmt.Sprintf("\"./%s\" || exit $?", scriptPath)) 977 | } 978 | scriptPackage = append(scriptPackage, "", constManagedScriptSectionFoot) 979 | 980 | updatedScript := splitHook[:headAt] 981 | updatedScript = append(updatedScript, scriptPackage...) 982 | updatedScript = append(updatedScript, splitHook[tailAt:]...) 983 | 984 | err = ioutil.WriteFile(gitHookPath, []byte(strings.Join(updatedScript, "\n")), 985 | os.FileMode(0755)) 986 | if err != nil { 987 | return err 988 | } 989 | } 990 | } 991 | 992 | return nil 993 | } 994 | -------------------------------------------------------------------------------- /pkg/entrypoint/entrypoint.go: -------------------------------------------------------------------------------- 1 | package entrypoint 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/smtp" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | "time" 15 | 16 | "github.com/alecthomas/kong" 17 | "github.com/jordan-wright/email" 18 | "github.com/pkg/errors" 19 | "github.com/samber/lo" 20 | "github.com/wrouesnel/emailcli/version" 21 | "go.uber.org/zap" 22 | ) 23 | 24 | type EmailOptions struct { 25 | Username string `help:"Username to authenticate to the SMTP server with"` 26 | Password string `help:"Password to authenticate to the SMTP server with"` 27 | Host string `help:"Hostname"` 28 | Port uint16 `help:"Port number" default:"25"` 29 | TLSHost string `help:"Hostname to use for verifying TLS (default to host if blank)" default:""` 30 | Attachments []string `help:"Files to attach to the email" type:"existingfile"` 31 | Subject string `help:"Subject line of the email"` 32 | Body string `help:"Body of email. Read from stdin if blank."` 33 | From string `help:"From address for the email."` 34 | To []string `help:"Email recipients." arg:""` 35 | Timeout time.Duration `help:"Timeout for mail sending."` 36 | PoolSize uint `name:"concurrent-sends" help:"Maximum concurrent send jobs." default:"1"` 37 | TLSInsecureSkipVerify bool `name:"insecure-skip-verify" help:"Disable TLS certificate authentication" default:"false"` 38 | TLSCertificateAuthority string `name:"cacert" help:"Specify a custom CA certificate to verify against."` 39 | HelloHostname string `name:"hello-hostname" help:"Hostname to use for SMTP HELO request" default:"localhost"` 40 | } 41 | 42 | type Options struct { 43 | Logging struct { 44 | Level string `help:"logging level" default:"warn"` 45 | Format string `help:"logging format (${enum})" enum:"console,json" default:"console"` 46 | } `embed:"" prefix:"logging."` 47 | 48 | Version bool `help:"Print the version and exit"` 49 | 50 | Email EmailOptions `embed:""` 51 | } 52 | 53 | type LaunchArgs struct { 54 | StdIn io.Reader 55 | StdOut io.Writer 56 | StdErr io.Writer 57 | Env map[string]string 58 | Args []string 59 | } 60 | 61 | // Entrypoint implements the actual functionality of the program so it can be called inline from testing. 62 | // env is normally passed the environment variable array. 63 | // 64 | //nolint:funlen,gocognit,gocyclo,cyclop,maintidx 65 | func Entrypoint(args LaunchArgs) int { 66 | var err error 67 | options := Options{} 68 | 69 | deferredLogs := []string{} 70 | 71 | // Command line parsing can now happen 72 | parser := lo.Must(kong.New(&options, kong.Description(version.Description), 73 | kong.DefaultEnvars(version.EnvPrefix))) 74 | _, err = parser.Parse(args.Args) 75 | if err != nil { 76 | _, _ = fmt.Fprintf(args.StdErr, "Argument error: %s", err.Error()) 77 | return 1 78 | } 79 | 80 | // Initialize logging as soon as possible 81 | logConfig := zap.NewProductionConfig() 82 | if err := logConfig.Level.UnmarshalText([]byte(options.Logging.Level)); err != nil { 83 | deferredLogs = append(deferredLogs, err.Error()) 84 | } 85 | logConfig.Encoding = options.Logging.Format 86 | 87 | logger, err := logConfig.Build() 88 | if err != nil { 89 | // Error unhandled since this is a very early failure 90 | for _, line := range deferredLogs { 91 | _, _ = io.WriteString(args.StdErr, line) 92 | } 93 | _, _ = io.WriteString(args.StdErr, "Failure while building logger") 94 | return 1 95 | } 96 | 97 | // Install as the global logger 98 | zap.ReplaceGlobals(logger) 99 | 100 | logger.Info("Launched with command line", zap.Strings("cmdline", args.Args)) 101 | 102 | if options.Version { 103 | lo.Must(fmt.Fprintf(args.StdOut, "%s", version.Version)) 104 | return 0 105 | } 106 | 107 | logger.Info("Version Info", zap.String("version", version.Version), 108 | zap.String("name", version.Name), 109 | zap.String("description", version.Description), 110 | zap.String("env_prefix", version.EnvPrefix)) 111 | 112 | appCtx, cancelFn := context.WithCancel(context.Background()) 113 | sigCh := make(chan os.Signal, 1) 114 | signal.Notify(sigCh, syscall.SIGTERM) 115 | 116 | go func() { 117 | for sig := range sigCh { 118 | logger.Info("Caught signal", zap.String("signal", sig.String())) 119 | cancelFn() 120 | return 121 | } 122 | }() 123 | 124 | logger.Info("Starting command") 125 | err = SendEmail(appCtx, options.Email) 126 | 127 | logger.Debug("Finished command") 128 | if err != nil { 129 | logger.Error("Command exited with error", zap.Error(err)) 130 | return 1 131 | } 132 | logger.Info("Command exited successfully") 133 | return 0 134 | } 135 | 136 | // SendEmail implements the eactual email sending. 137 | func SendEmail(ctx context.Context, options EmailOptions) error { 138 | logger := zap.L().With( 139 | zap.String("smtp_host", options.Host), 140 | zap.Uint16("smtp_port", options.Port), 141 | zap.String("smtp_username", options.Username)) 142 | 143 | if options.Timeout == 0 { 144 | options.Timeout = -1 145 | } 146 | logger.Debug("Timeout set", zap.Duration("timeout", options.Timeout)) 147 | 148 | if options.Password == "" { 149 | logger.Warn("Supplied SMTP password is blank!") 150 | } 151 | 152 | var bodytxt []byte 153 | if options.Body == "" { 154 | logger.Debug("Reading body text from stdin") 155 | var err error 156 | bodytxt, err = io.ReadAll(os.Stdin) 157 | if err != nil { 158 | return errors.Wrap(err, "Error reading from stdin") 159 | } 160 | } else { 161 | logger.Debug("Reading body text from options") 162 | bodytxt = []byte(options.Body) 163 | } 164 | 165 | err := func() error { 166 | tlsConf := new(tls.Config) 167 | if options.TLSHost != "" { 168 | logger.Debug("TLS Host set from options", zap.String("tls_host", options.TLSHost)) 169 | tlsConf.ServerName = options.TLSHost 170 | } else { 171 | logger.Debug("TLS Host set to server hostname", zap.String("tls_host", options.Host)) 172 | tlsConf.ServerName = options.Host 173 | } 174 | tlsConf.InsecureSkipVerify = options.TLSInsecureSkipVerify 175 | if tlsConf.InsecureSkipVerify { 176 | logger.Warn("Skipping certificate verification by user request") 177 | } 178 | 179 | if options.TLSCertificateAuthority != "" { 180 | logger.Debug("Loading certificate pool from file", zap.String("cacerts", options.TLSCertificateAuthority)) 181 | certs := x509.NewCertPool() 182 | 183 | pemData, err := os.ReadFile(options.TLSCertificateAuthority) 184 | if err != nil { 185 | return errors.Wrapf(err, "Error loading custom root CA: %s", options.TLSCertificateAuthority) 186 | } 187 | 188 | certs.AppendCertsFromPEM(pemData) 189 | tlsConf.RootCAs = certs 190 | } else { 191 | logger.Debug("Using default certificate pool") 192 | } 193 | 194 | logger.Debug("Initialize email pool") 195 | sendPool, perr := email.NewPool( 196 | net.JoinHostPort(options.Host, fmt.Sprintf("%v", options.Port)), 197 | int(options.PoolSize), 198 | smtp.PlainAuth("", options.Username, options.Password, options.Host), 199 | tlsConf, 200 | ) 201 | if perr != nil { 202 | return errors.Wrap(perr, "Error creating email pool") 203 | } 204 | sendPool.SetHelloHostname(options.HelloHostname) 205 | //defer sendPool.Close() 206 | 207 | logger.Info("Sending email to recipients", zap.Int("num_recipients", len(options.To))) 208 | numSuccessful := 0 209 | numFailed := 0 210 | for _, recipient := range options.To { 211 | logger.Info("Sending email", zap.String("recipient", recipient)) 212 | m := email.NewEmail() 213 | m.From = options.From 214 | m.To = []string{recipient} 215 | m.Subject = options.Subject 216 | m.Text = bodytxt 217 | 218 | for _, filename := range options.Attachments { 219 | _, err := m.AttachFile(filename) 220 | if err != nil { 221 | logger.Error("Error attaching file", zap.String("filename", filename), zap.Error(err)) 222 | return err 223 | } 224 | } 225 | 226 | if err := sendPool.Send(m, options.Timeout); err != nil { 227 | logger.Warn("Failed", zap.String("recipient", recipient), zap.Error(err)) 228 | numFailed += 1 229 | } else { 230 | logger.Info("Success", zap.String("recipient", recipient)) 231 | numSuccessful += 1 232 | } 233 | 234 | } 235 | 236 | if numFailed == len(options.To) { 237 | return errors.New("No emails were sent successfully") 238 | } 239 | 240 | logger.Warn("Some emails failed to send", zap.Int("success", numSuccessful), zap.Int("failed", numFailed), zap.Int("num_recipients", len(options.To))) 241 | 242 | return nil 243 | }() 244 | 245 | if err != nil { 246 | return errors.Wrap(err, "Error ending mail") 247 | } 248 | return nil 249 | } 250 | -------------------------------------------------------------------------------- /pkg/envutil/inputprocessors.go: -------------------------------------------------------------------------------- 1 | package envutil 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // EnvironmentVariablesError is raised when an environment variable is improperly formatted. 10 | type EnvironmentVariablesError struct { 11 | Reason string 12 | RawEnvVar string 13 | } 14 | 15 | // Error implements error. 16 | func (eev EnvironmentVariablesError) Error() string { 17 | return fmt.Sprintf("%s: %s", eev.Reason, eev.RawEnvVar) 18 | } 19 | 20 | // FromEnvironment consumes the environment and outputs a valid input data field into the 21 | // supplied map. 22 | func FromEnvironment(env []string) (map[string]string, error) { 23 | results := map[string]string{} 24 | 25 | if env == nil { 26 | env = os.Environ() 27 | } 28 | 29 | const expectedArgs = 2 30 | 31 | for _, keyval := range env { 32 | splitKeyVal := strings.SplitN(keyval, "=", expectedArgs) 33 | if len(splitKeyVal) != expectedArgs { 34 | return results, error(EnvironmentVariablesError{ 35 | Reason: "Could not find an equals value to split on", 36 | RawEnvVar: keyval, 37 | }) 38 | } 39 | } 40 | 41 | return results, nil 42 | } 43 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | // Version is populated by the build system. 4 | // 5 | //nolint:gochecknoglobals 6 | var Version = "development" 7 | 8 | const Name = "email" 9 | const EnvPrefix = "EMAIL" 10 | const Description = "Command Line Email Client" 11 | --------------------------------------------------------------------------------