diff --git a/.cirrus.yml b/.cirrus.yml deleted file mode 100644 index 8ca7373..0000000 --- a/.cirrus.yml +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: MIT - -freebsd_task: - name: FreeBSD - - matrix: - - name: FreeBSD 13.3 - freebsd_instance: - image_family: freebsd-13-3 - - name: FreeBSD 14.0 - freebsd_instance: - image_family: freebsd-14-0 - - env: - TEST_SKIP_SENDMAIL: 1 - - pkginstall_script: - - pkg install -y go - - test_script: - - go test -race -cover -shuffle=on ./... \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f4f40d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,218 @@ +# SPDX-FileCopyrightText: 2024 The go-mail Authors +# +# SPDX-License-Identifier: MIT + +name: CI + +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + +jobs: + codecov: + name: Test with Codecov coverage (${{ matrix.os }} / ${{ matrix.go }}) + runs-on: ${{ matrix.os }} + concurrency: + group: ci-codecov-${{ matrix.os }}-${{ matrix.go }} + cancel-in-progress: true + strategy: + matrix: + os: [ubuntu-latest] + go: ['1.23'] + env: + PERFORM_ONLINE_TEST: ${{ vars.PERFORM_ONLINE_TEST }} + PERFORM_UNIX_OPEN_WRITE_TESTS: ${{ vars.PERFORM_UNIX_OPEN_WRITE_TESTS }} + PERFORM_SENDMAIL_TESTS: ${{ vars.PERFORM_SENDMAIL_TESTS }} + TEST_HOST: ${{ secrets.TEST_HOST }} + TEST_USER: ${{ secrets.TEST_USER }} + TEST_PASS: ${{ secrets.TEST_PASS }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: Setup go + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version: ${{ matrix.go }} + check-latest: true + - name: Install sendmail + run: | + sudo apt-get -y update >/dev/null && sudo apt-get -y upgrade >/dev/null && sudo DEBIAN_FRONTEND=noninteractive apt-get -y install nullmailer >/dev/null && which sendmail + - name: Run go test + if: success() + run: | + go test -race -shuffle=on --coverprofile=coverage.coverprofile --covermode=atomic ./... + - name: Upload coverage to Codecov + if: success() + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + lint: + name: golangci-lint (${{ matrix.go }}) + runs-on: ubuntu-latest + concurrency: + group: ci-lint-${{ matrix.go }} + cancel-in-progress: true + strategy: + matrix: + go: ['1.23'] + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Setup go + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version: ${{ matrix.go }} + check-latest: true + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: golangci-lint + uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 + with: + version: latest + dependency-review: + name: Dependency review + runs-on: ubuntu-latest + concurrency: + group: ci-dependency-review + cancel-in-progress: true + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: 'Dependency Review' + uses: actions/dependency-review-action@a6993e2c61fd5dc440b409aa1d6904921c5e1894 # v4.3.5 + govulncheck: + name: Go vulnerabilities check + runs-on: ubuntu-latest + concurrency: + group: ci-govulncheck + cancel-in-progress: true + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Run govulncheck + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + test: + name: Test (${{ matrix.os }} / ${{ matrix.go }}) + runs-on: ${{ matrix.os }} + concurrency: + group: ci-test-${{ matrix.os }}-${{ matrix.go }} + cancel-in-progress: true + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go: ['1.19', '1.20', '1.21', '1.22', '1.23'] + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: Setup go + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version: ${{ matrix.go }} + - name: Run go test + run: | + go test -race -shuffle=on ./... + test-fbsd: + name: Test on FreeBSD ${{ matrix.osver }} + runs-on: ubuntu-latest + concurrency: + group: ci-test-freebsd-${{ matrix.osver }} + cancel-in-progress: true + strategy: + matrix: + osver: ['14.1', '14.0', 13.4'] + steps: + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: Run go test on FreeBSD + uses: vmactions/freebsd-vm@v1 + with: + usesh: true + copyback: false + prepare: | + pkg install -y go + run: | + cd $GITHUB_WORKSPACE; + go test -race -shuffle=on ./... + reuse: + name: REUSE Compliance Check + runs-on: ubuntu-latest + concurrency: + group: ci-reuse + cancel-in-progress: true + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: REUSE Compliance Check + uses: fsfe/reuse-action@3ae3c6bdf1257ab19397fab11fd3312144692083 # v4.0.0 + sonarqube: + name: Test with SonarQube review (${{ matrix.os }} / ${{ matrix.go }}) + runs-on: ${{ matrix.os }} + concurrency: + group: ci-codecov-${{ matrix.go }} + cancel-in-progress: true + strategy: + matrix: + os: [ubuntu-latest] + go: ['1.23'] + env: + PERFORM_ONLINE_TEST: ${{ vars.PERFORM_ONLINE_TEST }} + TEST_HOST: ${{ secrets.TEST_HOST }} + TEST_USER: ${{ secrets.TEST_USER }} + TEST_PASS: ${{ secrets.TEST_PASS }} + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: Setup go + uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 + with: + go-version: ${{ matrix.go }} + check-latest: true + - name: Run go test + run: | + go test -shuffle=on -race --coverprofile=./cov.out ./... + - name: SonarQube scan + uses: sonarsource/sonarqube-scan-action@884b79409bbd464b2a59edc326a4b77dc56b2195 # master + if: success() + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + - name: SonarQube quality gate + uses: sonarsource/sonarqube-quality-gate-action@dc2f7b0dd95544cd550de3028f89193576e958b9 # master + timeout-minutes: 5 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} \ No newline at end of file diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml deleted file mode 100644 index b9661f8..0000000 --- a/.github/workflows/codecov.yml +++ /dev/null @@ -1,67 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: CC0-1.0 - -name: Codecov workflow -on: - push: - branches: - - main - paths: - - '**.go' - - 'go.*' - - '.github/workflows/codecov.yml' - - 'codecov.yml' - pull_request: - branches: - - main - paths: - - '**.go' - - 'go.*' - - '.github/workflows/codecov.yml' - - 'codecov.yml' -env: - TEST_HOST: ${{ secrets.TEST_HOST }} - TEST_FROM: ${{ secrets.TEST_USER }} - TEST_ALLOW_SEND: "1" - TEST_SMTPAUTH_USER: ${{ secrets.TEST_USER }} - TEST_SMTPAUTH_PASS: ${{ secrets.TEST_PASS }} - TEST_SMTPAUTH_TYPE: "LOGIN" - TEST_ONLINE_SCRAM: "1" - TEST_HOST_SCRAM: ${{ secrets.TEST_HOST_SCRAM }} - TEST_USER_SCRAM: ${{ secrets.TEST_USER_SCRAM }} - TEST_PASS_SCRAM: ${{ secrets.TEST_PASS_SCRAM }} -permissions: - contents: read - -jobs: - run: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - go: ['1.23'] - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - - name: Checkout Code - uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master - - name: Setup go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version: ${{ matrix.go }} - - name: Install sendmail - if: matrix.go == '1.23' && matrix.os == 'ubuntu-latest' - run: | - sudo apt-get -y install sendmail; which sendmail - - name: Run Tests - run: | - go test -race --coverprofile=coverage.coverprofile --covermode=atomic ./... - - name: Upload coverage to Codecov - if: success() && matrix.go == '1.23' && matrix.os == 'ubuntu-latest' - uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index 56b5c10..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,31 +0,0 @@ -# SPDX-FileCopyrightText: 2022-2023 The go-mail Authors -# -# SPDX-License-Identifier: CC0-1.0 - -# Dependency Review Action -# -# This Action will scan dependency manifest files that change as part of a Pull Request, -# surfacing known-vulnerable versions of the packages declared or updated in the PR. -# Once installed, if the workflow run is marked as required, -# PRs introducing known-vulnerable packages will be blocked from merging. -# -# Source repository: https://github.com/actions/dependency-review-action -name: 'Dependency Review' -on: [pull_request] - -permissions: - contents: read - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - - name: 'Checkout Repository' - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - name: 'Dependency Review' - uses: actions/dependency-review-action@a6993e2c61fd5dc440b409aa1d6904921c5e1894 # v4.3.5 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml deleted file mode 100644 index 7313e04..0000000 --- a/.github/workflows/golangci-lint.yml +++ /dev/null @@ -1,54 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: CC0-1.0 - -name: golangci-lint -on: - push: - tags: - - v* - branches: - - main - pull_request: -permissions: - contents: read - # Optional: allow read access to pull request. Use with `only-new-issues` option. - # pull-requests: read -jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version: '1.23' - - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - - name: golangci-lint - uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 - with: - # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version - version: latest - - # Optional: working directory, useful for monorepos - # working-directory: somedir - - # Optional: golangci-lint command line arguments. - # args: --issues-exit-code=0 - - # Optional: show only new issues if it's a pull request. The default value is `false`. - # only-new-issues: true - - # Optional: if set to true then the all caching functionality will be complete disabled, - # takes precedence over all other caching options. - # skip-cache: true - - # Optional: if set to true then the action don't cache or restore ~/go/pkg. - # skip-pkg-cache: true - - # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. - # skip-build-cache: true diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml deleted file mode 100644 index 9d5cdfb..0000000 --- a/.github/workflows/govulncheck.yml +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: CC0-1.0 - -name: Govulncheck Security Scan - -on: [push, pull_request] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - name: Run govulncheck - uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 \ No newline at end of file diff --git a/.github/workflows/offline-tests.yml b/.github/workflows/offline-tests.yml deleted file mode 100644 index 22cddd7..0000000 --- a/.github/workflows/offline-tests.yml +++ /dev/null @@ -1,45 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: CC0-1.0 - -name: Offline tests workflow -on: - push: - branches: - - main - paths: - - '**.go' - - 'go.*' - - '.github/workflows/offline-tests.yml' - pull_request: - branches: - - main - paths: - - '**.go' - - 'go.*' - - '.github/workflows/offline-tests.yml' -permissions: - contents: read - -jobs: - run: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - go: ['1.19', '1.20', '1.21', '1.22', '1.23'] - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - - name: Checkout Code - uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master - - name: Setup go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version: ${{ matrix.go }} - - name: Run Tests - run: | - go test -race -shuffle=on ./... \ No newline at end of file diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml deleted file mode 100644 index 04fd414..0000000 --- a/.github/workflows/reuse.yml +++ /dev/null @@ -1,23 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: CC0-1.0 - -name: REUSE Compliance Check - -on: [push, pull_request] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - - name: REUSE Compliance Check - uses: fsfe/reuse-action@3ae3c6bdf1257ab19397fab11fd3312144692083 # v4.0.0 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml deleted file mode 100644 index d260ef7..0000000 --- a/.github/workflows/sonarqube.yml +++ /dev/null @@ -1,56 +0,0 @@ -# SPDX-FileCopyrightText: 2022 Winni Neessen -# -# SPDX-License-Identifier: CC0-1.0 - -name: SonarQube - -permissions: - contents: read - -on: - push: - branches: - - main - paths: - - '**.go' - - 'go.*' - - '.github/workflows/sonarqube.yml' - pull_request: - branches: - - main - paths: - - '**.go' - - 'go.*' - - '.github/workflows/sonarqube.yml' -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - name: Harden Runner - uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 - with: - egress-policy: audit - - - uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0 - with: - fetch-depth: 0 - - - name: Setup Go - uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 - with: - go-version: '1.23' - - - name: Run unit Tests - run: | - go test -shuffle=on -race --coverprofile=./cov.out ./... - - - uses: sonarsource/sonarqube-scan-action@884b79409bbd464b2a59edc326a4b77dc56b2195 # master - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} - - - uses: sonarsource/sonarqube-quality-gate-action@dc2f7b0dd95544cd550de3028f89193576e958b9 # master - timeout-minutes: 5 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.gitignore b/.gitignore index 3c0fb1e..5ce8347 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,6 @@ crashlytics.properties crashlytics-build.properties fabric.properties -testdata \ No newline at end of file +## Coverage data +coverage.coverprofile +coverage.html \ No newline at end of file diff --git a/b64linebreaker.go b/b64linebreaker.go index cc83973..abc356a 100644 --- a/b64linebreaker.go +++ b/b64linebreaker.go @@ -13,8 +13,8 @@ import ( // in encoding processes. var newlineBytes = []byte(SingleNewLine) -// ErrNoOutWriter is the error message returned when no io.Writer is set for Base64LineBreaker. -const ErrNoOutWriter = "no io.Writer set for Base64LineBreaker" +// ErrNoOutWriter is the error returned when no io.Writer is set for Base64LineBreaker. +var ErrNoOutWriter = errors.New("no io.Writer set for Base64LineBreaker") // Base64LineBreaker handles base64 encoding with the insertion of new lines after a certain number // of characters. @@ -44,7 +44,7 @@ type Base64LineBreaker struct { // - err: An error if one occurred during the write operation. func (l *Base64LineBreaker) Write(data []byte) (numBytes int, err error) { if l.out == nil { - err = errors.New(ErrNoOutWriter) + err = ErrNoOutWriter return } if l.used+len(data) < MaxBodyLength { diff --git a/b64linebreaker_test.go b/b64linebreaker_test.go index 9340d8d..dc08471 100644 --- a/b64linebreaker_test.go +++ b/b64linebreaker_test.go @@ -5,487 +5,165 @@ package mail import ( - "bufio" "bytes" "encoding/base64" "errors" - "fmt" "io" "os" "testing" ) -const logoB64 = `PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PCFE -T0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53 -My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHdpZHRoPSIxMDAlIiBo -ZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCA2MDAgNjAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJo -dHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3Jn -LzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3 -LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtz -dHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGlt -aXQ6MS41OyI+PHJlY3QgaWQ9ItCc0L7QvdGC0LDQttC90LDRjy3QvtCx0LvQsNGB0YLRjDEiIHNl -cmlmOmlkPSLQnNC+0L3RgtCw0LbQvdCw0Y8g0L7QsdC70LDRgdGC0YwxIiB4PSIwIiB5PSIwIiB3 -aWR0aD0iNjAwIiBoZWlnaHQ9IjYwMCIgc3R5bGU9ImZpbGw6bm9uZTsiLz48Zz48cGF0aCBkPSJN -NTg0LjcyNiw5OC4zOTVjLTAsLTkuNzM4IC03LjkwNywtMTcuNjQ1IC0xNy42NDUsLTE3LjY0NWwt -NTEwLjAyNywwYy05LjczOSwwIC0xNy42NDUsNy45MDcgLTE3LjY0NSwxNy42NDVsLTAsMzE5Ljc5 -NGMtMCw5LjczOSA3LjkwNiwxNy42NDYgMTcuNjQ1LDE3LjY0Nmw1MTAuMDI3LC0wYzkuNzM4LC0w -IDE3LjY0NSwtNy45MDcgMTcuNjQ1LC0xNy42NDZsLTAsLTMxOS43OTRaIiBzdHlsZT0iZmlsbDoj -MmMyYzJjO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyLjFweDsiLz48cGF0aCBkPSJNNTg1LjEy -Niw5OC4zOTVjLTAsLTkuNzM4IC03LjkwNywtMTcuNjQ1IC0xNy42NDUsLTE3LjY0NWwtNDk0Ljgz -OSwwYy05LjczOCwwIC0xNy42NDUsNy45MDcgLTE3LjY0NSwxNy42NDVsMCwzMTkuNzk0YzAsOS43 -MzkgNy45MDcsMTcuNjQ2IDE3LjY0NSwxNy42NDZsNDk0LjgzOSwtMGM5LjczOCwtMCAxNy42NDUs -LTcuOTA3IDE3LjY0NSwtMTcuNjQ2bC0wLC0zMTkuNzk0WiIgc3R5bGU9ImZpbGw6IzM3MzczNztz -dHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Mi4wN3B4OyIvPjxwYXRoIGQ9Ik01NjcuMjU5LDExNC40 -NzJjLTAsLTguNzYgLTcuMTEyLC0xNS44NzIgLTE1Ljg3MiwtMTUuODcybC00NjIuNjUxLDBjLTgu -NzYsMCAtMTUuODcyLDcuMTEyIC0xNS44NzIsMTUuODcybDAsMjg3LjY0MWMwLDguNzYgNy4xMTIs -MTUuODcxIDE1Ljg3MiwxNS44NzFsNDYyLjY1MSwwYzguNzYsMCAxNS44NzIsLTcuMTExIDE1Ljg3 -MiwtMTUuODcxbC0wLC0yODcuNjQxWiIgc3R5bGU9ImZpbGw6I2ZmZjJlYjtzdHJva2U6IzAwMDtz -dHJva2Utd2lkdGg6MS45cHg7Ii8+PHBhdGggZD0iTTIzNi4xNDUsNDM1Ljg3OGwtMjMuNDIsOTEu -MDA4bDEzMC43NjUsLTBsMjIuMzY1LC05MC43NTZsLTEyOS43MSwtMC4yNTJaIiBzdHlsZT0iZmls -bDojMmMyYzJjO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyLjQ3cHg7Ii8+PHBhdGggZD0iTTI0 -NS4zOTcsNDM1Ljg3OGwtMjMuNDIxLDkxLjAwOGwxMzAuNzY2LC0wbDIyLjM2NSwtOTAuNzU2bC0x -MjkuNzEsLTAuMjUyWiIgc3R5bGU9ImZpbGw6IzM3MzczNztzdHJva2U6IzAwMDtzdHJva2Utd2lk -dGg6Mi40N3B4OyIvPjxwYXRoIGQ9Ik00NzIuNTg0LDUzMS4wOTNjLTAsLTIuODI3IC0yLjI5NSwt -NS4xMjEgLTUuMTIxLC01LjEyMWwtMjkyLjU0MSwtMGMtMi44MjYsLTAgLTUuMTIxLDIuMjk0IC01 -LjEyMSw1LjEyMWwwLDEwLjI0M2MwLDIuODI2IDIuMjk1LDUuMTIxIDUuMTIxLDUuMTIxbDI5Mi41 -NDEsLTBjMi44MjYsLTAgNS4xMjEsLTIuMjk1IDUuMTIxLC01LjEyMWwtMCwtMTAuMjQzWiIgc3R5 -bGU9ImZpbGw6IzJjMmMyYztzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Mi40N3B4OyIvPjxnPjxw -YXRoIGQ9Ik0xOTAuMzc5LDQzMS40NTVjLTAsLTEuOTE2IC0xLjU1NiwtMy40NzIgLTMuNDcyLC0z -LjQ3MmwtNTQuNTYzLDBjLTEuOTE3LDAgLTMuNDcyLDEuNTU2IC0zLjQ3MiwzLjQ3MmwtMCw0Ni4z -MDljLTAsMS45MTYgMS41NTUsMy40NzIgMy40NzIsMy40NzJsNTQuNTYzLC0wYzEuOTE2LC0wIDMu -NDcyLC0xLjU1NiAzLjQ3MiwtMy40NzJsLTAsLTQ2LjMwOVoiIHN0eWxlPSJmaWxsOiNlZWFjMDE7 -c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjMuMXB4OyIvPjxnPjxwYXRoIGQ9Ik0xMzkuOTk2LDQ0 -Mi44NTNjLTAsMC40MiAtMC4wNzEsMC45MjIgMC4wNjMsMS4zMjNjMC41MywxLjU5MiAyLjc1LDEu -Mzg2IDQuMDk1LDEuMzg2YzIuMzk3LC0wIDQuNjc0LC0wLjEwOCA2LjQ4OSwtMS44MjdjMC40NSwt -MC40MjYgMC45NjYsLTAuOTQgMS4wNzIsLTEuNTc1YzAuMDYxLC0wLjM2NyAwLjEwMiwtMS4yMDgg -LTAuMTI2LC0xLjUxMmMtMC44MDMsLTEuMDcxIC0yLjg0NCwtMC4zNzMgLTMuNjU1LDAuMjUyYy0w -LjcxNCwwLjU1IC0wLjgyMiwxLjgyNiAtMC45NDUsMi42NDZjLTAuNDA2LDIuNzA1IDAuMzg3LDUu -MDMgMy4xNSw1Ljg1OWMzLjQwNSwxLjAyMSA2Ljc4NCwwLjA2IDEwLjA4NiwtMC44MDljMS41OTcs -LTAuNDIxIDMuNjYsLTAuNzcgNS4wMTcsLTEuNzJjMC40NTIsLTAuMzE2IDAuNDQ0LC0wLjI4NCAw -LjkwMSwtMC41NThjMS4yMDgsLTAuNzI1IDMuODY5LC0xLjkyNyAyLjQ1NywtMy41OTFjLTAuMTks -LTAuMjI0IC0xLjEzOSwtMC4yMzcgLTEuMjYsMC4xMjZjLTAuNDIsMS4yNTcgLTEuMzI2LDMuMzAy -IDAuNTY3LDMuNzhjMS4zOTgsMC4zNTMgMi45MTksMC4xODkgNC4zNDcsMC4xODljMC4xOTMsLTAg -MS40ODUsLTAuMjc5IDEuNzAxLC0wLjA2M2MwLjU1NSwwLjU1NSAxLjU0MywwLjgxMSAyLjMzMSwx -LjAwOGMwLjkzMiwwLjIzMyAxLjk0NCwwLjYwNiAyLjg5OSwwLjY5M2MwLjg2OSwwLjA3OSAxLjc5 -LC0wLjA0NSAyLjY0NiwwLjEyNiIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Ut -d2lkdGg6MS45NnB4OyIvPjxwYXRoIGQ9Ik0xNDIuNzA1LDQ1Mi42ODFjMC4xMTksMC41NTEgMC44 -OTMsMC42NjkgMS4zMTgsMC44NDZjMS41OTgsMC42NjYgMy41MTQsMC44NzQgNS4yMzQsMC45ODJj -Mi4zNzgsMC4xNDggNC43MDQsLTAuMTU5IDcuMDU3LC0wLjMxNWMxLjg2MSwtMC4xMjUgMy43NDYs -MC4xMTYgNS42MDcsLTBjMi4wNTEsLTAuMTI5IDQuMzIxLC0wLjM3NyA2LjM2NCwtMC4wNjNjMS4w -NzIsMC4xNjQgMi4wODksMC40OCAzLjE1LDAuNjkzIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZToj -MDAwO3N0cm9rZS13aWR0aDoxLjk2cHg7Ii8+PHBhdGggZD0iTTE3Ni4zNDksNDU1LjA3NmMxLjEx -MywtMC4wMjEgMi4yMjYsLTAuMDQyIDMuMzQsLTAuMDYzIiBzdHlsZT0iZmlsbDpub25lO3N0cm9r -ZTojMDAwO3N0cm9rZS13aWR0aDoxLjk2cHg7Ii8+PHBhdGggZD0iTTE0NS4xNjIsNDU5Ljg2NGwy -MS42NzQsLTAiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjEuOTZw -eDsiLz48L2c+PC9nPjxnPjxnPjxwYXRoIGQ9Ik03Ni4yNzMsMTIxLjQ5NGw0ODcuNTU3LC0wLjAx -NSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6I2Y0ZGRkMDtzdHJva2Utd2lkdGg6NC45M3B4O3N0 -cm9rZS1saW5lY2FwOnNxdWFyZTsiLz48cGF0aCBkPSJNMjMwLjk1MSwyMjQuMTc1Yy0wLjgwMiwt -My4zMjcgLTguMDU5LC03Ljk4NCAtMTEuMDA1LC04Ljk0NWMtMzUuODQ2LC0xMS42OTkgLTIwLjc4 -MywyNy4zNzIgLTAuNjE4LDI0LjM0MiIgc3R5bGU9ImZpbGw6IzYxZTBlZTsiLz48cGF0aCBkPSJN -MjM0Ljk0NCwyMjMuMjEzYy0wLjM3OSwtMS41NzQgLTEuNTIyLC0zLjQzNiAtMy4yNzYsLTUuMTc0 -Yy0zLjA2MSwtMy4wMzMgLTguMDE0LC01LjkyIC0xMC40NDgsLTYuNzE0Yy0xMy4xMzUsLTQuMjg3 -IC0yMC42MTgsLTEuOTk0IC0yNC4wNzEsMS44MTljLTMuNjM4LDQuMDE3IC0zLjgzNiwxMC40OTIg -LTAuODI1LDE2LjY1MWMzLjk4MSw4LjE0MyAxMy4zMjEsMTUuMzg2IDIzLjYxNSwxMy44MzljMi4y -NDIsLTAuMzM3IDMuNzg4LC0yLjQzIDMuNDUxLC00LjY3MmMtMC4zMzcsLTIuMjQyIC0yLjQzLC0z -Ljc4OSAtNC42NzIsLTMuNDUyYy02LjY0NywwLjk5OSAtMTIuNDQ0LC00LjA2NSAtMTUuMDE0LC05 -LjMyM2MtMC44MDEsLTEuNjM3IC0xLjI5MywtMy4zMDggLTEuMzAyLC00Ljg0N2MtMC4wMDUsLTEu -MDIgMC4xOTUsLTEuOTc0IDAuODM2LC0yLjY4MmMwLjgzMiwtMC45MTggMi4yMjUsLTEuMzU0IDQu -MTMzLC0xLjQ4MWMyLjg1MiwtMC4xOTEgNi41NjcsMC40MTIgMTEuMywxLjk1N2MxLjQ1NiwwLjQ3 -NSA0LjE2LDIuMDk2IDYuMjU3LDMuODY5YzAuNjM2LDAuNTM3IDEuMjE1LDEuMDg4IDEuNjU4LDEu -NjM2YzAuMTUyLDAuMTg4IDAuMzMxLDAuMzI3IDAuMzcyLDAuNDk4YzAuNTMxLDIuMjA0IDIuNzUx -LDMuNTYyIDQuOTU1LDMuMDMxYzIuMjA0LC0wLjUzMSAzLjU2MiwtMi43NTIgMy4wMzEsLTQuOTU1 -WiIvPjxwYXRoIGQ9Ik0zNDkuMzI2LDEyMy41MjdjMCwwIC0xNS4zNSwtMTMuODYyIC0xOC4xMTQs -MjIuODM0IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojNjFlMGVlO3N0cm9rZS13aWR0aDo2LjE2 -cHg7Ii8+PHBhdGggZD0iTTI5OS42NjIsMTIzLjQ4NWMtMCwtMCAxMi42NzEsLTE1LjI2NSAyMC4x -MTIsMjEuODk4IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojNjFlMGVlO3N0cm9rZS13aWR0aDo2 -LjE2cHg7Ii8+PHBhdGggZD0iTTMzNi4xMTEsMTA4Ljc3M2MtMCwwIC0xNi44MDgsLTkuMDk5IC0x -MC44ODIsMzIuMTI0IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojNjFlMGVlO3N0cm9rZS13aWR0 -aDo2LjE2cHg7Ii8+PHBhdGggZD0iTTQxNy43OTYsMTg0LjQ1M2MzLjI4MiwwLjk3IDYuNTgzLC0x -LjI5IDguODg5LC0zLjM2MWMyOC4wNTUsLTI1LjE5NCAtMTMuMTk4LC0zMS41MzggLTIwLjY1Niwt -MTIuNTU5IiBzdHlsZT0iZmlsbDojNjFlMGVlOyIvPjxwYXRoIGQ9Ik00MTYuNjMyLDE4OC4zOTNj -NC42NTcsMS4zNzUgOS41MjYsLTEuMzA3IDEyLjc5NywtNC4yNDVjMTAuMjc5LC05LjIzIDEyLjA0 -NiwtMTYuODQzIDEwLjQ4OCwtMjEuNzE2Yy0xLjY0NSwtNS4xNSAtNy4xNDIsLTguNTQ0IC0xMy45 -NzgsLTguOTljLTkuMDExLC0wLjU4NyAtMTkuOTI3LDMuOTA0IC0yMy43MzMsMTMuNTg5Yy0wLjgz -LDIuMTEgMC4yMSw0LjQ5NiAyLjMyLDUuMzI1YzIuMTEsMC44MjkgNC40OTYsLTAuMjExIDUuMzI1 -LC0yLjMyMWMyLjQ2LC02LjI1OSA5LjczLC04Ljc3NSAxNS41NTMsLTguMzk1YzEuODA3LDAuMTE3 -IDMuNDg5LDAuNTE3IDQuODE3LDEuMjY5YzAuODcxLDAuNDk0IDEuNTg2LDEuMTMgMS44NzEsMi4w -MjNjMC4zNzUsMS4xNzIgMC4wNDksMi41ODggLTAuNzk1LDQuMjk4Yy0xLjI2NiwyLjU2MiAtMy42 -NDksNS40NzcgLTcuMzU2LDguODA2Yy0wLjg2OCwwLjc4IC0xLjkwNywxLjYxOCAtMy4wNTQsMi4x -NDdjLTAuNjE2LDAuMjg0IC0xLjI2NSwwLjUyNyAtMS45MjgsMC4zMzFjLTIuMTc0LC0wLjY0MiAt -NC40NjEsMC42MDIgLTUuMTAzLDIuNzc2Yy0wLjY0MiwyLjE3NCAwLjYwMiw0LjQ2MSAyLjc3Niw1 -LjEwM1oiLz48cGF0aCBkPSJNNDM2LjAxNCw0MTkuNjQ1YzMuNjAxLC0xMC45MzQgOS4wMDMsLTMx -LjYwOSA1LjY4MiwtNDkuNTYzYy0xNC45NTMsLTgwLjg0NyA3Ljg5NSwtOTguNTQ5IC01LjAzNSwt -MTQ3LjU5M2MtNC40OSwtMTcuMDI5IC0xMi4wNDksLTM2LjI1IC0yMy41NTQsLTQ5Ljc5MmMtMjUu -Mjg1LC0yOS43NjIgLTcwLjczNiwtNDIuOTEzIC0xMDguNzMsLTMyLjc4NWMtMTUuMjEzLDQuMDU1 -IC0yOC42NjIsMTEuNzI3IC00MS45MjMsMjAuMDM0Yy00Ni4xMzYsMjguOSAtNTIuMjU5LDgzLjM3 -NyAtNDIuNzkxLDEyOC4yNDhjNC4xOTEsMTkuODY0IDIzLjY3Myw0OS44OTcgMjIuNTg2LDcwLjE5 -NGMtMS4zODcsMjUuOTEgLTAuNzY1LDQ0LjQyNiAzLjM3Niw2MC44ODQiIHN0eWxlPSJmaWxsOiM2 -MWUwZWU7Ii8+PGNsaXBQYXRoIGlkPSJfY2xpcDEiPjxwYXRoIGQ9Ik00MzYuMDE0LDQxOS42NDVj -My42MDEsLTEwLjkzNCA5LjAwMywtMzEuNjA5IDUuNjgyLC00OS41NjNjLTE0Ljk1MywtODAuODQ3 -IDcuODk1LC05OC41NDkgLTUuMDM1LC0xNDcuNTkzYy00LjQ5LC0xNy4wMjkgLTEyLjA0OSwtMzYu -MjUgLTIzLjU1NCwtNDkuNzkyYy0yNS4yODUsLTI5Ljc2MiAtNzAuNzM2LC00Mi45MTMgLTEwOC43 -MywtMzIuNzg1Yy0xNS4yMTMsNC4wNTUgLTI4LjY2MiwxMS43MjcgLTQxLjkyMywyMC4wMzRjLTQ2 -LjEzNiwyOC45IC01Mi4yNTksODMuMzc3IC00Mi43OTEsMTI4LjI0OGM0LjE5MSwxOS44NjQgMjMu -NjczLDQ5Ljg5NyAyMi41ODYsNzAuMTk0Yy0xLjM4NywyNS45MSAtMC43NjUsNDQuNDI2IDMuMzc2 -LDYwLjg4NCIvPjwvY2xpcFBhdGg+PGcgY2xpcC1wYXRoPSJ1cmwoI19jbGlwMSkiPjxwYXRoIGQ9 -Ik0zMTEuNzc2LDIzNi44MzVjMCwwIC0zLjA2OCwyNC4xOTkgLTMxLjU5NywyNC44MzhjLTI4LjUz -LDAuNjM5IC0zMi41OTEsLTI4LjI0NiAtMzIuNTkxLC0yOC4yNDZsNjQuMTg4LDMuNDA4WiIgc3R5 -bGU9ImZpbGw6IzRmY2JlMDsiLz48cGF0aCBkPSJNNDMyLjUwOSwyMTYuMDIzYzAsLTAgLTMuMDY3 -LDI0LjE5OCAtMzEuNTk3LDI0LjgzN2MtMjguNTMsMC42MzkgLTMyLjU5MSwtMjguMjQ2IC0zMi41 -OTEsLTI4LjI0Nmw2NC4xODgsMy40MDlaIiBzdHlsZT0iZmlsbDojNGZjYmUwOyIvPjxwYXRoIGQ9 -Ik0yODYuODQ1LDQxOC45NjFsMTI3Ljg4NiwtMGMwLjM4NiwtMS4yNjcgMC42NjYsLTIuNDMyIDAu -ODcsLTMuNDcxYzMuODY3LC0xOS43NDMgMy43MjksLTU3LjQ2OCAtMi40MTUsLTc3LjA5NWMtMi4x -MzMsLTYuODE2IC01Ljc5MywtMTQuNDkgLTExLjQ3NiwtMTkuODUyYy0xMi40OSwtMTEuNzg1IC02 -NC4zOTgsLTkuMTgxIC04My41NjEsLTQuODM1Yy03LjY3MywxLjc0MSAtMTQuNDg3LDQuOTIyIC0y -MS4yMSw4LjM1OGMtMTQuOTExLDcuNjIgLTIwLjI1Myw1OC43MTIgLTEwLjA5NCw5Ni44OTVaIiBz -dHlsZT0iZmlsbDojYjdmOGZmOyIvPjxwYXRoIGQ9Ik0yNDYuNzExLDQxOC45NjFjLTUuMTA4LC0y -MC42MTMgLTYuNDksLTQyLjk3NSAtNS41NjIsLTYwLjg1NWMyLjEzNiwtNDEuMTMzIC0zMS4zMTQs -LTUwLjQ1MyAtMjUuNDYxLC0xMjEuNTEzYzE0LjUzNCwtNDIuNDA5IDE1LjA4OSwtNDAuMTA3IDI3 -LjY1MywtNTUuNDQ1YzAsMCAtMTMuMTQ3LDM5Ljk2OCAtMS4xOTMsNzAuNzI4YzExLjk1NCwzMC43 -NTkgMzIuMTc0LDczLjQxOCAzMi41OSwxMTcuNDkxYzAuMTg2LDE5Ljc3MSAxLjYzNCwzNi4zMSA0 -LjEzLDQ5LjU5NGwtMzIuMTU3LC0wWiIgc3R5bGU9ImZpbGw6IzRmY2JlMDsiLz48L2c+PHBhdGgg -ZD0iTTQzOS45MTUsNDIwLjkzYzMuNzQ4LC0xMS4zOCA5LjI3NiwtMzIuOTA4IDUuODIsLTUxLjU5 -NWMtOC44NjUsLTQ3LjkzMyAtNC4yNTksLTczLjQwNSAtMS45MzUsLTk2LjU4YzEuNjEzLC0xNi4w -OTMgMi4xNTYsLTMxLjEyMiAtMy4xNjcsLTUxLjMxM2MtNC42MzgsLTE3LjU5MSAtMTIuNTExLC0z -Ny40MTYgLTI0LjM5NiwtNTEuNDA1Yy0yNi4yNjEsLTMwLjkxMSAtNzMuNDU2LC00NC42MTMgLTEx -Mi45MTcsLTM0LjA5NGMtMTUuNjE0LDQuMTYxIC0yOS40MzcsMTEuOTk2IC00My4wNDcsMjAuNTIy -Yy00Ny43MzUsMjkuOTAyIC01NC40MjYsODYuMTUgLTQ0LjYyOSwxMzIuNTc3YzIuMTM5LDEwLjEz -OCA4LjEzNiwyMi44ODggMTMuNTA0LDM1LjY3NmM0Ljk5NCwxMS45IDkuNTE1LDIzLjgxIDguOTk5 -LDMzLjQ1Yy0xLjQxNSwyNi40MzIgLTAuNzMsNDUuMzE3IDMuNDk1LDYyLjEwN2wxLjAwMiwzLjk4 -M2w3Ljk2NywtMi4wMDVsLTEuMDAyLC0zLjk4M2MtNC4wNTgsLTE2LjEyNyAtNC42MTgsLTM0LjI3 -NCAtMy4yNTgsLTU5LjY2M2MwLjQ2LC04LjYwOSAtMi40NiwtMTguODk1IC02LjU0NSwtMjkuNDU3 -Yy01LjY5NCwtMTQuNzI0IC0xMy42NDYsLTMwLjA1OSAtMTYuMTI0LC00MS44MDRjLTkuMTQsLTQz -LjMxNSAtMy41ODQsLTk2LjAyMiA0MC45NTIsLTEyMy45MTljMTIuOTEyLC04LjA4OSAyNS45ODks -LTE1LjU5OCA0MC44MDEsLTE5LjU0N2MzNi41MjYsLTkuNzM1IDgwLjIzNCwyLjg2NSAxMDQuNTQx -LDMxLjQ3NmMxMS4xMjYsMTMuMDk2IDE4LjM3MiwzMS43MTMgMjIuNzEzLDQ4LjE4YzYuMzEsMjMu -OTMyIDMuODIsNDAuMjE5IDEuNjQ3LDYwLjM4M2MtMi4yNTcsMjAuOTQ5IC00LjI1OSw0NS45Mjcg -My4zMjEsODYuOTFjMy4xODYsMTcuMjIyIC0yLjA5LDM3LjA0MyAtNS41NDQsNDcuNTMxbC0xLjI4 -NSwzLjkwMmw3LjgwMywyLjU2OWwxLjI4NCwtMy45MDFaIi8+PHBhdGggZD0iTTQyNC41MzksMjQy -LjI0N2MtMS44NjgsMC4xMzQgLTMuMjc2LDEuNzU4IC0zLjE0MiwzLjYyNmMwLjEzMywxLjg2NyAx -Ljc1NywzLjI3NSAzLjYyNSwzLjE0MWMxLjg2NywtMC4xMzMgMy4yNzUsLTEuNzU3IDMuMTQyLC0z -LjYyNWMtMC4xMzQsLTEuODY3IC0xLjc1OCwtMy4yNzUgLTMuNjI1LC0zLjE0MloiIHN0eWxlPSJm -aWxsOiNiN2Y4ZmY7Ii8+PHBhdGggZD0iTTI1Ni45OTcsMjYwLjM5MWMtMS44NjgsMC4xMzMgLTMu -Mjc1LDEuNzU4IC0zLjE0MiwzLjYyNWMwLjEzMywxLjg2OCAxLjc1OCwzLjI3NSAzLjYyNSwzLjE0 -MmMxLjg2OCwtMC4xMzMgMy4yNzUsLTEuNzU4IDMuMTQyLC0zLjYyNWMtMC4xMzMsLTEuODY4IC0x -Ljc1OCwtMy4yNzUgLTMuNjI1LC0zLjE0MloiIHN0eWxlPSJmaWxsOiNiN2Y4ZmY7Ii8+PGc+PHBh -dGggZD0iTTM2NC4wOSwyNjkuMjI5Yy0xLjE4Niw0Ljk2NyAtMS40NDMsMTQuMzA2IC00LjMxNSwx -OC40Yy0xLjM5MywxLjk4NSAtNC40OTUsMi4wODYgLTYuNTE2LDIuMTUzYy0xMS42MTIsMC4zODMg -LTExLjQ0NiwtMTEuNTEzIC0xMy4xODEsLTIxLjg1NSIgc3R5bGU9ImZpbGw6I2VkZWRlZDsiLz48 -cGF0aCBkPSJNMzYxLjA5NCwyNjguNTEzYy0wLjcxOSwzLjAwOSAtMS4xMDYsNy42MDcgLTEuOTIy -LDExLjY5OGMtMC40NDUsMi4yMjggLTAuOTcxLDQuMjk4IC0xLjkxOSw1LjY0OWMtMC4yNjMsMC4z -NzYgLTAuNzQ5LDAuNDU5IC0xLjIwMywwLjU2NmMtMC45OTUsMC4yMzMgLTIuMDU2LDAuMjUgLTIu -ODkzLDAuMjc3Yy0yLjA1NSwwLjA2OCAtMy42MSwtMC4zMjYgLTQuNzc0LC0xLjE5MmMtMS4xOTMs -LTAuODg3IC0xLjk1NCwtMi4yMTIgLTIuNTQ0LC0zLjc0M2MtMS41NjEsLTQuMDQ3IC0xLjg5LC05 -LjM5IC0yLjcyMywtMTQuMzUxYy0wLjI4MSwtMS42NzcgLTEuODcxLC0yLjgxIC0zLjU0OCwtMi41 -MjhjLTEuNjc3LDAuMjgxIC0yLjgwOSwxLjg3MSAtMi41MjgsMy41NDhjMC45MDMsNS4zODEgMS4z -NTgsMTEuMTU4IDMuMDUxLDE1LjU0OGMxLjAzNiwyLjY4OCAyLjUyLDQuOTEyIDQuNjE1LDYuNDdj -Mi4xMjUsMS41ODEgNC45MDMsMi41MyA4LjY1NCwyLjQwNmMxLjQ2OCwtMC4wNDggMy40LC0wLjE1 -MiA1LjA3OSwtMC43MTRjMS41NiwtMC41MjIgMi45MTksLTEuNDEgMy44NTgsLTIuNzQ5YzEuMzUs -LTEuOTI0IDIuMjg0LC00LjgwOCAyLjkxNywtNy45ODFjMC44LC00LjAxMSAxLjE2OCwtOC41MjIg -MS44NzMsLTExLjQ3M2MwLjM5NSwtMS42NTMgLTAuNjI3LC0zLjMxNyAtMi4yODEsLTMuNzEyYy0x -LjY1NCwtMC4zOTUgLTMuMzE3LDAuNjI3IC0zLjcxMiwyLjI4MVoiLz48cGF0aCBkPSJNMzQ5LjAw -MSwyMzQuMDVjLTkuMzMyLDEuMjQ2IC0xNi4yNjUsNy4wNzUgLTE1LjQ3MywxMy4wMDhjMC43OTIs -NS45MzMgOS4wMTIsOS43MzggMTguMzQ0LDguNDkyYzkuMzMzLC0xLjI0NiAxNi4yNjYsLTcuMDc1 -IDE1LjQ3NCwtMTMuMDA4Yy0wLjc5MiwtNS45MzMgLTkuMDEyLC05LjczOCAtMTguMzQ1LC04LjQ5 -MloiLz48cGF0aCBkPSJNMzQ4LjkwMSwyNTIuNTVjMC40Niw1LjIwNCAxLjY5NCwxMC4xNDIgMi40 -NTMsMTUuMjc3YzAuMjQ4LDEuNjgyIDEuODE1LDIuODQ2IDMuNDk3LDIuNTk4YzEuNjgyLC0wLjI0 -OCAyLjg0NiwtMS44MTYgMi41OTgsLTMuNDk4Yy0wLjc0MSwtNS4wMTUgLTEuOTYxLC05LjgzNyAt -Mi40MTEsLTE0LjkyYy0wLjE1LC0xLjY5NCAtMS42NDcsLTIuOTQ3IC0zLjM0LC0yLjc5N2MtMS42 -OTQsMC4xNSAtMi45NDcsMS42NDcgLTIuNzk3LDMuMzRaIi8+PHBhdGggZD0iTTM3NC4yNiwyNTku -ODE0Yy04LjAyMiw1LjM1IC0xNS45MDEsNy44MjUgLTIzLjU5NCw3LjI3NGMtNy42OTQsLTAuNTUx -IC0xNS4xNDgsLTQuMTI0IC0yMi4zNDIsLTEwLjU4OWMtMS4yNjUsLTEuMTM2IC0zLjIxNCwtMS4w -MzIgLTQuMzUxLDAuMjMzYy0xLjEzNiwxLjI2NCAtMS4wMzIsMy4yMTQgMC4yMzMsNC4zNWM4LjM1 -Niw3LjUwOCAxNy4wODQsMTEuNTExIDI2LjAyLDEyLjE1MWM4LjkzNiwwLjY0IDE4LjEzNSwtMi4w -NzggMjcuNDUzLC04LjI5NGMxLjQxNCwtMC45NDMgMS43OTcsLTIuODU4IDAuODUzLC00LjI3MmMt -MC45NDMsLTEuNDE1IC0yLjg1OCwtMS43OTcgLTQuMjcyLC0wLjg1M1oiLz48cGF0aCBkPSJNMzc4 -LjYwNSwxNTIuMTU2Yy0zLjY0NywtMC44MjYgLTYuOTU2LDAuMDM4IC03LjM4NSwxLjkyOWMtMC40 -MjgsMS44OTEgMi4xODUsNC4wOTcgNS44MzIsNC45MjRjMy42NDcsMC44MjcgNi45NTYsLTAuMDM3 -IDcuMzg1LC0xLjkyOGMwLjQyOCwtMS44OTEgLTIuMTg1LC00LjA5OCAtNS44MzIsLTQuOTI1WiIg -c3R5bGU9ImZpbGw6I2I3ZjhmZjsiLz48cGF0aCBkPSJNMjQ5LjUsMTc3LjI3Yy0yLjE5NSwzLjAy -OCAtMi43MDIsNi40MSAtMS4xMzIsNy41NDhjMS41NywxLjEzOCA0LjYyNiwtMC4zOTYgNi44MjEs -LTMuNDI0YzIuMTk1LC0zLjAyOCAyLjcwMiwtNi40MSAxLjEzMiwtNy41NDhjLTEuNTcsLTEuMTM4 -IC00LjYyNiwwLjM5NiAtNi44MjEsMy40MjRaIiBzdHlsZT0iZmlsbDojYjdmOGZmOyIvPjxwYXRo -IGQ9Ik00MDMuNjQ4LDE2Mi44MjVjLTE5LjIxMywtMS4zNSAtMzUuOTA3LDEzLjE1MSAtMzcuMjU3 -LDMyLjM2NGMtMS4zNSwxOS4yMTIgMTMuMTUyLDM1LjkwNiAzMi4zNjQsMzcuMjU2YzE5LjIxMywx -LjM1MSAzNS45MDcsLTEzLjE1MSAzNy4yNTcsLTMyLjM2NGMxLjM1LC0xOS4yMTIgLTEzLjE1Miwt -MzUuOTA2IC0zMi4zNjQsLTM3LjI1NloiIHN0eWxlPSJmaWxsOiNmZmY7c3Ryb2tlOiMwMDA7c3Ry -b2tlLXdpZHRoOjYuMTZweDsiLz48cGF0aCBkPSJNMjgxLjY5NiwxODEuNzhjLTE5LjIxMiwtMS4z -NSAtMzUuOTA2LDEzLjE1MiAtMzcuMjU3LDMyLjM2NGMtMS4zNSwxOS4yMTIgMTMuMTUyLDM1Ljkw -NyAzMi4zNjUsMzcuMjU3YzE5LjIxMiwxLjM1IDM1LjkwNiwtMTMuMTUyIDM3LjI1NiwtMzIuMzY0 -YzEuMzUsLTE5LjIxMyAtMTMuMTUyLC0zNS45MDcgLTMyLjM2NCwtMzcuMjU3WiIgc3R5bGU9ImZp -bGw6I2ZmZjtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Ni4xNnB4OyIvPjxwYXRoIGQ9Ik0zOTgu -NDk5LDE5Mi44ODFjLTYuODI0LC0wLjQ3OSAtMTIuNzU0LDQuNjcyIC0xMy4yMzMsMTEuNDk2Yy0w -LjQ4LDYuODI0IDQuNjcxLDEyLjc1MyAxMS40OTUsMTMuMjMzYzYuODI0LDAuNDc5IDEyLjc1NCwt -NC42NzIgMTMuMjMzLC0xMS40OTZjMC40OCwtNi44MjQgLTQuNjcxLC0xMi43NTMgLTExLjQ5NSwt -MTMuMjMzWiIvPjxwYXRoIGQ9Ik0yNzguMTQyLDIxMC4yMjdjLTYuODI0LC0wLjQ3OSAtMTIuNzU0 -LDQuNjcxIC0xMy4yMzMsMTEuNDk2Yy0wLjQ4LDYuODI0IDQuNjcxLDEyLjc1MyAxMS40OTUsMTMu -MjMzYzYuODI0LDAuNDc5IDEyLjc1NCwtNC42NzIgMTMuMjMzLC0xMS40OTZjMC40OCwtNi44MjQg -LTQuNjcxLC0xMi43NTMgLTExLjQ5NSwtMTMuMjMzWiIvPjxwYXRoIGQ9Ik0zOTkuODkyLDE5Ny45 -NjFjLTEuOTE2LC0wLjEzNSAtMy41ODEsMS4zMTEgLTMuNzE2LDMuMjI3Yy0wLjEzNCwxLjkxNiAx -LjMxMiwzLjU4MSAzLjIyOCwzLjcxNmMxLjkxNiwwLjEzNCAzLjU4MSwtMS4zMTIgMy43MTUsLTMu -MjI4YzAuMTM1LC0xLjkxNiAtMS4zMTEsLTMuNTgxIC0zLjIyNywtMy43MTVaIiBzdHlsZT0iZmls -bDojZmZmOyIvPjxwYXRoIGQ9Ik0yNzkuNTM1LDIxNS4zMDZjLTEuOTE2LC0wLjEzNCAtMy41ODEs -MS4zMTIgLTMuNzE1LDMuMjI4Yy0wLjEzNSwxLjkxNiAxLjMxMSwzLjU4MSAzLjIyNywzLjcxNmMx -LjkxNiwwLjEzNCAzLjU4MSwtMS4zMTIgMy43MTYsLTMuMjI4YzAuMTM0LC0xLjkxNiAtMS4zMTIs -LTMuNTgxIC0zLjIyOCwtMy43MTZaIiBzdHlsZT0iZmlsbDojZmZmOyIvPjwvZz48L2c+PHBhdGgg -ZD0iTTUxMi4yOTEsMzMzLjcyNmMwLC0wIC0yNy40OTYsMTMuNDAyIC00My4yNDUsMy43OTJjLTE1 -Ljc1LC05LjYxIC0xMC44LC0yNC43ODggMy4xMDIsLTIyLjUyYzEzLjkwMywyLjI2OCAyNy4xNjgs -My4xOTUgMzUuOTQzLC0zLjkzN2M4Ljc3NiwtNy4xMzIgMjEuNjMzLDE0LjM3NiA0LjIsMjIuNjY1 -WiIgc3R5bGU9ImZpbGw6I2Q2YzI5NTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Ni4xNnB4OyIv -PjwvZz48Zz48cGF0aCBkPSJNNDQxLjI2OCwyOTEuMzQ5Yy0wLDAgMjguMDgsNDcuNDg4IC0xMjIu -NTkxLDExNC4wNGMtMTEuNjI5LC0yLjAxNyAtMTQuMzMsLTguNTE1IC0xNC4zMywtOC41MTVjMCwt -MCA0My42MTcsLTEyLjU4NiA4My44MTIsLTQ0Ljg3NmM0My41OTcsLTM1LjAyNCA1My4xMDksLTYw -LjY0OSA1My4xMDksLTYwLjY0OVoiIHN0eWxlPSJmaWxsOiM3YjQ3MjI7c3Ryb2tlOiMwMDA7c3Ry -b2tlLXdpZHRoOjYuNjhweDsiLz48cGF0aCBkPSJNMjMzLjk1NywzNzIuMTM4YzMuNTYsLTUuNTA2 -IDEwLjcyOSwtNy4zOTIgMTYuNTM4LC00LjM1MWMxNy43NjcsOS4yMzUgNTMuNjE0LDI4LjAzMiA2 -Ny4xNTQsMzYuMzc2YzUuOTcxLDMuNjggLTYuNDc3LDE2LjkxMyAtMTkuMjgzLDM3LjkyN2MtMy4z -NjgsNS41NjcgLTEwLjQzNiw3LjYyOCAtMTYuMjY5LDQuNzQ2Yy0yMS42NTYsLTEwLjcxNiAtNDEu -MDQ3LC0yMC45NTggLTU0LjIzNCwtMjguMDczYy00LjU5MiwtMi41MDEgLTcuOTM0LC02LjgwMiAt -OS4yMjMsLTExLjg2OWMtMS4yODgsLTUuMDY4IC0wLjQwNywtMTAuNDQzIDIuNDMzLC0xNC44MzNj -NC4zMzUsLTYuNzA4IDkuMTYyLC0xNC4xNyAxMi44ODQsLTE5LjkyM1oiIHN0eWxlPSJmaWxsOiM3 -YjQ3MjI7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48cGF0aCBkPSJNMjM5Ljg1 -LDM2MS4xNzFsLTI1Ljg0OCwyMy4yOTZsNzkuMTcsNDUuNjA1bDI1LjE5NiwtMjQuMjk1bC03OC41 -MTgsLTQ0LjYwNloiIHN0eWxlPSJmaWxsOiNhZDZjNGE7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRo -OjQuOTVweDsiLz48cGF0aCBkPSJNMjM4LjA4MSwzNzcuNzA0YzIuNTI2LDAuNTMgNC4xNDcsMy4w -MTEgMy42MTgsNS41MzdjLTAuNTMsMi41MjYgLTMuMDExLDQuMTQ3IC01LjUzOCwzLjYxOGMtMi41 -MjYsLTAuNTMgLTQuMTQ3LC0zLjAxMSAtMy42MTcsLTUuNTM4YzAuNTMsLTIuNTI2IDMuMDExLC00 -LjE0NyA1LjUzNywtMy42MTdaIiBzdHlsZT0iZmlsbDojZmZiMjVjO3N0cm9rZTojMDAwO3N0cm9r -ZS13aWR0aDo0Ljk1cHg7Ii8+PHBhdGggZD0iTTI5MC4zMyw0MDcuOWMyLjUyNywwLjUzIDQuMTQ3 -LDMuMDExIDMuNjE4LDUuNTM3Yy0wLjUzLDIuNTI3IC0zLjAxMSw0LjE0NyAtNS41MzcsMy42MThj -LTIuNTI3LC0wLjUzIC00LjE0OCwtMy4wMTEgLTMuNjE4LC01LjUzN2MwLjUzLC0yLjUyNyAzLjAx -MSwtNC4xNDggNS41MzcsLTMuNjE4WiIgc3R5bGU9ImZpbGw6I2ZmYjI1YztzdHJva2U6IzAwMDtz -dHJva2Utd2lkdGg6NC45NXB4OyIvPjwvZz48Zz48cGF0aCBkPSJNMzA1LjMyMywzMzYuMjM1YzAs -MCAyNC4yMDQsMTEyLjU3OCAzNS4zNTcsMTE0LjM1NWMxMS4xNTMsMS43NzYgMTgyLjk5OCwtNTcu -MTYyIDE4Mi45OTgsLTU3LjE2MmMwLC0wIC0yNC4yNywtMTA1Ljc3MyAtMzQuNDU2LC0xMTQuNjAy -Yy0xMC4xODYsLTguODMgLTE4My44OTksNTcuNDA5IC0xODMuODk5LDU3LjQwOVoiIHN0eWxlPSJm -aWxsOiNmZmYyZWI7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuNjhweDsiLz48cGF0aCBkPSJN -NDEzLjU2MywzNjcuNjU2bC0xMDguMjQsLTMxLjQyMWwxMDguMjQsMzEuNDIxWiIgc3R5bGU9ImZp -bGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6NS40NnB4OyIvPjxwYXRoIGQ9Ik00ODgu -NTY2LDI3OS4xNTZsLTc1LjAwMyw4OC41IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0 -cm9rZS13aWR0aDo1LjQ2cHg7Ii8+PHBhdGggZD0iTTMzOS4xNjcsNDQ5LjEyN2w2Mi40MDEsLTgz -LjUwNSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6NS40NnB4OyIv -PjxwYXRoIGQ9Ik00MjMuMjcsMzU1LjgzNWw5OS4xOTQsMzcuMDAxIiBzdHlsZT0iZmlsbDpub25l -O3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo1LjQ2cHg7Ii8+PC9nPjxwYXRoIGQ9Ik0zMDQuNDE2 -LDM3NC43ODVjLTAsMCAtMzAuNDYsLTIuOCAtMzguOTQzLC0xOS4xODVjLTguNDgyLC0xNi4zODQg -My42MjIsLTI2Ljc5NCAxNC4zMzIsLTE3LjY0NmMxMC43MTEsOS4xNDkgMjEuNTcyLDE2LjgyMSAz -Mi43NzQsMTUuMjc0YzExLjIwMSwtMS41NDcgMTEuMDQsMjMuNTExIC04LjE2MywyMS41NTdaIiBz -dHlsZT0iZmlsbDojZDZjMjk1O3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo2LjE2cHg7Ii8+PHBh -dGggZD0iTTEwOS45NTEsMzExLjcxMmw2OC4wNzYsLTAiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tl -OiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48cGF0aCBkPSJNMTM5LjMwNSwzNTEuNjgzbDY4 -LjA3NiwwIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo0Ljk1cHg7 -Ii8+PHBhdGggZD0iTTEzOS42MTcsMzMxLjY5OGwzOC4wOTgsLTAiIHN0eWxlPSJmaWxsOm5vbmU7 -c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48cGF0aCBkPSJNODUuMjAxLDIxMS4y -NTNjLTAsLTIuMjEzIC0xLjc5NywtNC4wMSAtNC4wMSwtNC4wMWwtNTMuNDg3LC0wYy0yLjIxNCwt -MCAtNC4wMTEsMS43OTcgLTQuMDExLDQuMDFsMCw1OC44OTRjMCwyLjIxMyAxLjc5Nyw0LjAxIDQu -MDExLDQuMDFsNTMuNDg3LDBjMi4yMTMsMCA0LjAxLC0xLjc5NyA0LjAxLC00LjAxbC0wLC01OC44 -OTRaIiBzdHlsZT0iZmlsbDojZWVhYzAxO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDozLjQzcHg7 -Ii8+PHBhdGggZD0iTTU2NC42NDMsNDI2LjU5MWMwLC0yLjIxMyAtMS43OTYsLTQuMDEgLTQuMDEs -LTQuMDFsLTUzLjQ4NywtMGMtMi4yMTMsLTAgLTQuMDEsMS43OTcgLTQuMDEsNC4wMWwwLDU4Ljg5 -NGMwLDIuMjEzIDEuNzk3LDQuMDEgNC4wMSw0LjAxbDUzLjQ4NywwYzIuMjE0LDAgNC4wMSwtMS43 -OTcgNC4wMSwtNC4wMWwwLC01OC44OTRaIiBzdHlsZT0iZmlsbDojZWVhYzAxO3N0cm9rZTojMDAw -O3N0cm9rZS13aWR0aDozLjQzcHg7Ii8+PGNpcmNsZSBjeD0iNTE3LjAxNCIgY3k9IjEwOC43NzEi -IHI9IjQuMzUyIiBzdHlsZT0iZmlsbDojZDNkMmIzOyIvPjxjaXJjbGUgY3g9IjUzMS4zMiIgY3k9 -IjEwOC43NzEiIHI9IjQuMzUyIiBzdHlsZT0iZmlsbDojZjRiMzAzOyIvPjxjaXJjbGUgY3g9IjU0 -NC43OTIiIGN5PSIxMDguNzcxIiByPSI0LjM1MiIgc3R5bGU9ImZpbGw6I2Y5NjEzZDsiLz48cGF0 -aCBkPSJNMTA1LjI1Nyw0ODQuNDNjLTAsMCAyMC4zNzQsLTI1LjIyNSA0MS43MTksLTcuNzYxYzIx -LjM0NCwxNy40NjMgLTEyLjYxMyw1MC40NSAtNDAuNzQ5LDQyLjY4OSIgc3R5bGU9ImZpbGw6bm9u -ZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6OS45cHg7Ii8+PHBhdGggZD0iTTExMy4wMTksNDYw -LjE0MWMtMCwtNC4yNjUgLTMuNDYzLC03LjcyNyAtNy43MjgsLTcuNzI3bC03OC42NTUsLTBjLTQu -MjY1LC0wIC03LjcyNywzLjQ2MiAtNy43MjcsNy43MjdsLTAsNzguNjU1Yy0wLDQuMjY1IDMuNDYy -LDcuNzI4IDcuNzI3LDcuNzI4bDc4LjY1NSwtMGM0LjI2NSwtMCA3LjcyOCwtMy40NjMgNy43Mjgs -LTcuNzI4bC0wLC03OC42NTVaIiBzdHlsZT0iZmlsbDojNjFlMGVlO3N0cm9rZTojMDAwO3N0cm9r -ZS13aWR0aDo0LjU1cHg7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlOyIvPjxwYXRoIGQ9Ik03Ny4yNzcs -Mzk1LjQwMmMtMi4yMzQsOC43NTUgLTYuNDk2LDE1LjM2NiAtMTEuNjQ5LDE3LjcxNGMxLjk5NSwz -LjA1NyAzLjE2Nyw2Ljc4MSAzLjE2NywxMC43OTdjMCwxMC4zODcgLTcuODQsMTguODIgLTE3LjQ5 -OCwxOC44MmMtOS42NTcsMCAtMTcuNDk4LC04LjQzMyAtMTcuNDk4LC0xOC44MmMwLC05LjczMyA2 -Ljg4NCwtMTcuNzUxIDE1LjY5NywtMTguNzIyYy0zLjM5OSwtNS45NiAtNS41MjQsLTE0LjQyMyAt -NS41MjQsLTIzLjgwNWMwLC0xOC4wMjQgNy44NDEsLTMyLjY1NyAxNy40OTksLTMyLjY1N2M2Ljc5 -MiwtMCAxMi42ODUsNy4yMzggMTUuNTg0LDE3LjgwMmMxLjYyOSwtMC41OTIgMy4zODcsLTAuOTE1 -IDUuMjIsLTAuOTE1YzguNDUsMCAxNS4zMTEsNi44NjEgMTUuMzExLDE1LjMxMWMtMCw4LjQ1IC02 -Ljg2MSwxNS4zMTEgLTE1LjMxMSwxNS4zMTFjLTEuNzUsMCAtMy40MzIsLTAuMjk0IC00Ljk5OCwt -MC44MzZaIiBzdHlsZT0iZmlsbDojZmZmO2ZpbGwtb3BhY2l0eTowLjU7Ii8+PHBhdGggZD0iTTMz -LjU4MywyMzMuOTNjMi41MjksLTAuMzM3IDQuNTEsLTEuMTc4IDYuNzE2LC0yLjQwM2MwLjk1Nywt -MC41MzIgMS45NjksLTAuOTA0IDIuODk4LC0xLjQ4NWMwLjc4OSwtMC40OTMgMS4zNDMsLTAuOTc2 -IDEuMzQzLC0xLjk3OWMwLC0wLjIzNiAwLjIyNCwtMC42MzIgMCwtMC43MDdjLTAuOTUyLC0wLjMx -NyAtMi4zNjQsMC40NjUgLTMuMDQsMC45OWMtMC42MjUsMC40ODYgLTEuNDk0LDAuNTc5IC0yLjEy -LDEuMDZjLTEuMDM2LDAuNzk2IC0xLjY4MiwyLjQ2MyAtMS43NjcsMy43NDZjLTAuMDYsMC44OTYg -LTAuMzU3LDIuODQgMC41NjUsMy4zOTNjMC4zNTcsMC4yMTQgMC45MjksMC4xNDIgMS4zNDMsMC4x -NDJjMS4wOCwtMCAyLjEwNCwwLjAxMiAzLjE4MSwtMC4xNDJjMi4wODIsLTAuMjk3IDQuMTY1LC0x -LjQ1NSA2LjAwOSwtMi40MDNjMi4wMTQsLTEuMDM2IDQuMDEyLC0xLjkyMSA1LjU4NCwtMy42MDVj -MC42NDIsLTAuNjg4IDEuMDEsLTEuMjg2IDEuMjcyLC0yLjE5MWMwLjA1OSwtMC4yMDMgLTAuMDYs -LTAuNzE4IDAuMDcxLC0wLjg0OWMwLjEwOCwtMC4xMDggMC4wOTksMC4yMjYgMC4wNzEsMC4yODNj -LTAuMTg1LDAuMzY3IC0wLjQ2MiwwLjc0IC0wLjYzNywxLjEzMWMtMC4zMjQsMC43MjkgLTAuMzUz -LDEuNzU5IC0wLjM1MywyLjU0NWMwLDAuMTMyIC0wLjA3LDAuNTcxIDAsMC42MzZjMC4wNTUsMC4w -NTEgMC4zNjMsMC4xOTMgMC40MjQsMC4yMTJjMi4wMDIsMC42MzMgNC4zNDIsLTAuNTMyIDYuMDA5 -LC0xLjQ4NGMwLjY0MSwtMC4zNjcgMS4zNjMsLTAuODUgMi4wNiwtMS4xMTJjMC4wMjYsLTAuMDEg -MC42NzgsLTAuMTk3IDAuNjk2LC0wLjE2MWMwLjI4MiwwLjU2MyAwLjExMSwxLjUxMSAwLjIxMiwy -LjEyMWMwLjI4NSwxLjcwNyAyLjM2NywxLjYxNSAzLjY3NiwxLjQ4NGMwLjIwNywtMC4wMiAwLjM2 -MywtMC4yMTIgMC41NjYsLTAuMjEyIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0cm9r -ZS13aWR0aDoyLjc1cHg7Ii8+PHBhdGggZD0iTTM2LjQ4MiwyNDIuMzQyYzEuMzQ2LC0wIDIuNjgy -LC0wLjE1NCA0LjAyOSwtMC4yMTJjNC4zMzIsLTAuMTg5IDguNjcxLC0wLjE5NiAxMy4wMDYsLTAu -MjgzYzYuNTI1LC0wLjEzMSAxMy4wNTYsMC4wNzEgMTkuNTgxLDAuMDcxIiBzdHlsZT0iZmlsbDpu -b25lO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyLjc1cHg7Ii8+PHBhdGggZD0iTTUxNi4wOSw0 -NDIuMDMxYy0wLDAuOTM2IC0wLjUzMiwxLjg5MiAtMC4xMjYsMi44MzljMC41MjYsMS4yMjggMi4z -NjMsMS4yNzUgMy40NjksMC45NDZjMC42MjgsLTAuMTg3IDEuMzE2LC0xLjE3NiAxLjcwMywtMS42 -NGMxLjAzOCwtMS4yNDcgMi40MzUsLTIuMzc1IDIuNzEyLC00LjAzN2MwLjA0MSwtMC4yNDggMC4x -OTQsLTAuOTM2IDAuMDYzLC0xLjE5OGMtMC43NjMsLTEuNTI1IC0zLjA1OSwtMS40NzggLTQuMTY4 -LC0wLjM2OWMtMC4xNjQsMC4xNjQgLTAuNDQ2LDAuMjI3IC0wLjU2MywwLjQzMmMtMC45OTQsMS43 -NDUgLTAuNDM5LDQuMzQxIDAuNDQyLDUuOTkyYzAuMzA0LDAuNTY5IDAuODI5LDEuMDQ4IDEuMzI0 -LDEuNDUxYzAuMzM5LDAuMjc1IDAuNzc1LDAuNzE2IDEuMTk5LDAuOTI4YzMuMjYyLDEuNjMxIDcu -Njg5LC0wLjEzMyAxMC41OTYsLTEuNzQ4YzEuOTUyLC0xLjA4NSA0LjEsLTIuMTUyIDQuMSwtNC42 -NjhjLTAsLTAuMzI1IDAuMDM3LC0wLjY5NCAtMC4wNjMsLTEuMDA5Yy0wLjM2MSwtMS4xMzMgLTIu -MzA3LC0xLjA0MyAtMy4xNTQsLTAuNjk0Yy0xLjE3OSwwLjQ4NiAtMi4zNzMsMS4zOTUgLTMuMDks -Mi40NmMtMS4xNzQsMS43NDIgLTEuMjA3LDQuMjgzIDAuNTY3LDUuNjE0YzEuMjU2LDAuOTQxIDIu -OTU4LDAuOTIzIDQuNDQ3LDAuNzY2YzIuMjQ3LC0wLjIzNyA0LjI1MywtMS4zMTMgNi4wMjMsLTIu -NjU5YzAuODc1LC0wLjY2NCAyLjA5NCwtMS4zMTYgMi42NDksLTIuMzMzYzAuMTczLC0wLjMxNiAw -LjIxNSwtMC43NDUgMC4zNzksLTEuMDczYzAuMDcxLC0wLjE0MyAtMC4xNTgsMC4yODUgLTAuMTg5 -LDAuNDQyYy0wLjA1NiwwLjI3NiAtMC4wOTQsMC44NzggLTAsMS4xMzVjMC4zNzYsMS4wMjggMS4y -MzMsMS41MDkgMi4yNywxLjcwM2MxLjM0NiwwLjI1MiAzLjAwOSwtMC4wMSA0LjM1MiwtMC4xODlj -MC45MzksLTAuMTI1IDEuODkyLC0wLjUxNyAxLjg5MiwwLjc1NyIgc3R5bGU9ImZpbGw6bm9uZTtz -dHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Mi40NXB4OyIvPjxwYXRoIGQ9Ik01MTQuMjU5LDQ2MC4z -NTljMC4yNjYsMC4xMzQgMC4wOCwxLjM1NCAwLjU2NywxLjM4OGMwLjQ0MiwwLjAzMSAwLjg4NSww -LjA0IDEuMzI4LDAuMDVjMC41MTcsMC4wMTIgMS4xMDQsMC4wOTggMS42MzMsLTAuMDM0YzEuNjg4 -LC0wLjQyMiAzLjc5NiwtMS41OTggNC40ODIsLTMuMjk2YzAuMTg4LC0wLjQ2NyAwLjI0NiwtMS4w -OTQgMC4zMTUsLTEuNTc3YzAuMDc1LC0wLjUyNCAwLjM3OCwtMS43MTggLTAuMDYzLC0yLjE0NGMt -MC4wODgsLTAuMDg1IC0wLjYwNCwtMC4wNjMgLTAuNjk0LC0wLjA2M2MtMC42OTUsLTAgLTEuNzk2 -LDAuMzkyIC0yLjIwNywxLjAwOWMtMC4yNzMsMC40MDkgLTAuMTg5LDAuODUxIC0wLjE4OSwxLjMy -NGMtMCwwLjYyIC0wLjAzMywxLjIyNCAwLjEyNiwxLjgzYzAuMzYyLDEuMzc5IDIuMTExLDEuOTQx -IDMuMzQzLDIuMDE4YzIuNjk2LDAuMTY4IDUuNDQ3LC0wLjI0NSA4LjEzNiwtMGMyLjIwNywwLjIg -NC40NTQsMC43MTcgNi42ODYsMC41MDRjMS41OCwtMC4xNSAzLjE1NCwtMC41MjUgNC43MywtMC42 -M2MzLjM5NCwtMC4yMjYgNi43OTEsLTAuNTA1IDEwLjIxOCwtMC41MDUiIHN0eWxlPSJmaWxsOm5v -bmU7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjIuNDVweDsiLz48cGF0aCBkPSJNNTE5LjA1NCw0 -NzEuNjhsMCwwLjk0NmMwLDAuMTU1IC0wLDAuMjUxIDAuMDYzLDAuMzc5bDAsMC4xMjZjMC42MDIs -LTIuNDA3IDMuNzc1LC0yLjc4NCA1LjgxLC0yLjc4NGMwLjE1LC0wIDAuMjMyLDAuNDggMC4yNDUs -MC41NzZjMC4wNTUsMC4zODUgLTAuMTkyLDEuNDUgMC4zNzksMS42NGMwLjk4OSwwLjMzIDIuNDA2 -LC0wLjMxOCAzLjM0MywtMC41NjhjMC4yMzUsLTAuMDYyIDEuMDA5LC0wLjQ0MSAxLjE5OCwtMC4y -NTJjMC45NTQsMC45NTQgMi40MTIsMC41NjIgMy43MjEsMC42MzFjMy4zMzIsMC4xNzUgNi42Mjks -LTAuMTg5IDkuOTY2LC0wLjE4OWMxLjA1OCwtMCAyLjE2MiwwLjA4NyAzLjIxNywtMGMwLjA2OCwt -MC4wMDYgMS4wMDksLTAuMDI3IDEuMDA5LC0wLjI1MyIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6 -IzAwMDtzdHJva2Utd2lkdGg6Mi40NXB4OyIvPjwvZz48Zz48cGF0aCBkPSJNMzc0LjU3OCwxMTQu -NDU1Yy0wLjYxMywtNC4yMTYgLTQuNTMzLC03LjE0MiAtOC43NDksLTYuNTNsLTk5Ljk0NSwxNC41 -MTVjLTQuMjE2LDAuNjEzIC03LjE0Miw0LjUzMyAtNi41Myw4Ljc0OWwyLjIxOSwxNS4yNzhjMC42 -MTIsNC4yMTYgNC41MzIsNy4xNDIgOC43NDksNi41M2w5OS45NDUsLTE0LjUxNWM0LjIxNiwtMC42 -MTIgNy4xNDIsLTQuNTMzIDYuNTI5LC04Ljc0OWwtMi4yMTgsLTE1LjI3OFoiIHN0eWxlPSJmaWxs -OiM5ZGFjY2Q7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjUuMjFweDsiLz48cGF0aCBkPSJNMzEz -Ljg2OSwxMTcuNDE0YzAsMCA2Ni4xMDIsOS44NzggNzcuNTgyLC0yMy43MzNjMTEuNDgsLTMzLjYx -MSAtMTIuOTg3LC01MS44NzMgLTQxLjYzNywtMzkuOTIxYy0yOC42NSwxMS45NTIgLTU0Ljg1LDQ2 -LjE0MiAtNTQuODUsNDYuMTQyYzAsMCAtMzAuMDA1LC04LjEwOCAtNDEuMzE0LDUuMjIxYy0xMS4z -MDksMTMuMzMgLTE3LjE2NywyNi42NDYgLTIuNDM1LDMxLjUxOWMxNC43MzIsNC44NzMgNjIuNjU0 -LC0xOS4yMjggNjIuNjU0LC0xOS4yMjhaIiBzdHlsZT0iZmlsbDojNzM1ZDkzO3N0cm9rZTojMDAw -O3N0cm9rZS13aWR0aDo1LjkzcHg7Ii8+PHBhdGggZD0iTTM3NC4xNDgsMTM2Ljg3NmMtMCwwIDM4 -LjQ5MywtOS4zMzggMzYuNTYzLC0zLjU0OWMtMS45Myw1Ljc5IC0zNy42MzgsMjYuOTAxIC0xMTQu -ODMxLDE1Ljk2NmM3MC4zNDUsLTEwLjczMSA3OC4yNjgsLTEyLjQxNyA3OC4yNjgsLTEyLjQxN1oi -IHN0eWxlPSJmaWxsOiM1MDNhNzE7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48 -cGF0aCBkPSJNMzU3LjM0NiwxMjQuOTA2bC01NC4wMjEsOS42MTJsLTAsNS4zOTlsNTQuMDIxLC05 -LjYxMmwwLC01LjM5OVoiIHN0eWxlPSJmaWxsOiNmZmY7Ii8+PGNpcmNsZSBjeD0iMzAwLjE1IiBj -eT0iMTM3LjE5OCIgcj0iNC42NzciIHN0eWxlPSJmaWxsOiNmZmIyNWM7c3Ryb2tlOiMwMDA7c3Ry -b2tlLXdpZHRoOjQuOTVweDsiLz48Y2lyY2xlIGN4PSIzNjEuNDEiIGN5PSIxMjcuNTgxIiByPSI0 -LjI4MiIgc3R5bGU9ImZpbGw6I2ZmYjI1YztzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6NC45NXB4 -OyIvPjwvZz48L3N2Zz4= -` - var ( - errMockDefault = errors.New("mock write error") - errMockNewline = errors.New("mock newline error") + errClosedWriter = errors.New("writer is already closed") + errMockDefault = errors.New("mock write error") + errMockNewline = errors.New("mock newline error") ) -// TestBase64LineBreaker tests the Write and Close methods of the Base64LineBreaker func TestBase64LineBreaker(t *testing.T) { - l, err := os.Open("assets/gopher2.svg") - if err != nil { - t.Errorf("failed to open gopher logo asset: %s", err) - return - } - defer func() { _ = l.Close() }() - - var wbuf bytes.Buffer - lb := Base64LineBreaker{out: &wbuf} - bw := base64.NewEncoder(base64.StdEncoding, &lb) - if _, err := io.Copy(bw, l); err != nil { - t.Errorf("failed to write logo asset to line breaker: %s", err) - return - } - if err := bw.Close(); err != nil { - t.Errorf("failed to close b64 encoder: %s", err) - } - if err := lb.Close(); err != nil { - t.Errorf("failed to close line breaker: %s", err) - } - ob := removeNewLines([]byte(logoB64)) - nb := removeNewLines(wbuf.Bytes()) - if string(ob) != string(nb) { - t.Errorf("generated line breaker output differs from original data") - } -} - -// TestBase64LineBreakerFailures tests the cases in which the Base64LineBreaker would fail -func TestBase64LineBreakerFailures(t *testing.T) { - stt := []byte("short") - ltt := []byte(logoB64) - - // No output writer defined - lb := Base64LineBreaker{} - if _, err := lb.Write(stt); err == nil { - t.Errorf("writing to Base64LineBreaker with no output io.Writer was supposed to failed, but didn't") - } - if err := lb.Close(); err != nil { - t.Errorf("failed to close Base64LineBreaker: %s", err) - } - - // Closed output writer - wbuf := errorWriter{} - fb := Base64LineBreaker{out: wbuf} - if _, err := fb.Write(ltt); err == nil { - t.Errorf("writing to Base64LineBreaker with errorWriter was supposed to failed, but didn't") - } - if err := fb.Close(); err != nil { - t.Errorf("failed to close Base64LineBreaker: %s", err) - } -} - -func TestBase64LineBreaker_WriteAndClose(t *testing.T) { - tests := []struct { - name string - data []byte - writer io.Writer - }{ - { - name: "Write data within MaxBodyLength", - data: []byte("testdata"), - writer: &mockWriterExcess{writeError: errMockDefault}, - }, - { - name: "Write data exceeds MaxBodyLength", - data: []byte("verylongtestdataverylongtestdataverylongtestdata" + - "verylongtestdataverylongtestdataverylongtestdata"), - writer: &mockWriterExcess{writeError: errMockDefault}, - }, - { - name: "Write data exceeds MaxBodyLength with newline", - data: []byte("verylongtestdataverylongtestdataverylongtestdata" + - "verylongtestdataverylongtestdataverylongtestdata"), - writer: &mockWriterNewline{writeError: errMockDefault}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - blr := &Base64LineBreaker{out: tt.writer} - - _, err := blr.Write(tt.data) - if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) { - t.Errorf("Unexpected error while writing: %v", err) - } - err = blr.Close() - if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) { - t.Errorf("Unexpected error while closing: %v", err) + t.Run("write, copy and close", func(t *testing.T) { + logoWriter := bytes.NewBuffer(nil) + lineBreaker := &Base64LineBreaker{out: logoWriter} + t.Cleanup(func() { + if err := lineBreaker.Close(); err != nil { + t.Errorf("failed to close line breaker: %s", err) } }) - } + if _, err := lineBreaker.Write([]byte("testdata")); err != nil { + t.Errorf("failed to write to line breaker: %s", err) + } + }) + t.Run("write actual data and compare with expected results", func(t *testing.T) { + logo, err := os.Open("testdata/logo.svg") + if err != nil { + t.Fatalf("failed to open test data file: %s", err) + } + t.Cleanup(func() { + if err := logo.Close(); err != nil { + t.Errorf("failed to close test data file: %s", err) + } + }) + + logoWriter := bytes.NewBuffer(nil) + lineBreaker := &Base64LineBreaker{out: logoWriter} + t.Cleanup(func() { + if err := lineBreaker.Close(); err != nil { + t.Errorf("failed to close line breaker: %s", err) + } + }) + base64Encoder := base64.NewEncoder(base64.StdEncoding, lineBreaker) + t.Cleanup(func() { + if err := base64Encoder.Close(); err != nil { + t.Errorf("failed to close base64 encoder: %s", err) + } + }) + copiedBytes, err := io.Copy(base64Encoder, logo) + if err != nil { + t.Errorf("failed to copy test data to line breaker: %s", err) + } + if err = base64Encoder.Close(); err != nil { + t.Errorf("failed to close base64 encoder: %s", err) + } + if err = lineBreaker.Close(); err != nil { + t.Errorf("failed to close line breaker: %s", err) + } + + logoStat, err := os.Stat("testdata/logo.svg") + if err != nil { + t.Fatalf("failed to stat test data file: %s", err) + } + if logoStat.Size() != copiedBytes { + t.Errorf("copied %d bytes, but expected %d bytes", copiedBytes, logoStat.Size()) + } + + expectedRaw, err := os.ReadFile("testdata/logo.svg.base64") + if err != nil { + t.Errorf("failed to read expected base64 data from file: %s", err) + } + expected := removeNewLines(t, expectedRaw) + got := removeNewLines(t, logoWriter.Bytes()) + if !bytes.EqualFold(expected, got) { + t.Errorf("generated line breaker output differs from expected data") + } + }) + t.Run("fail with no writer defined", func(t *testing.T) { + lineBreaker := &Base64LineBreaker{} + _, err := lineBreaker.Write([]byte("testdata")) + if err == nil { + t.Errorf("writing to Base64LineBreaker with no output io.Writer was supposed to failed, but didn't") + } + if !errors.Is(err, ErrNoOutWriter) { + t.Errorf("unexpected error while writing to empty Base64LineBreaker: %s", err) + } + if err := lineBreaker.Close(); err != nil { + t.Errorf("failed to close Base64LineBreaker: %s", err) + } + }) + t.Run("write on an already closed output writer", func(t *testing.T) { + logo, err := os.Open("testdata/logo.svg") + if err != nil { + t.Fatalf("failed to open test data file: %s", err) + } + t.Cleanup(func() { + if err := logo.Close(); err != nil { + t.Errorf("failed to close test data file: %s", err) + } + }) + + writeBuffer := &errorWriter{} + lineBreaker := &Base64LineBreaker{out: writeBuffer} + _, err = io.Copy(lineBreaker, logo) + if err == nil { + t.Errorf("writing to Base64LineBreaker with an already closed output io.Writer was " + + "supposed to failed, but didn't") + } + if !errors.Is(err, errClosedWriter) { + t.Errorf("unexpected error while writing to Base64LineBreaker: %s", err) + } + }) + t.Run("fail on different scenarios with mock writer", func(t *testing.T) { + tests := []struct { + name string + data []byte + writer io.Writer + }{ + { + name: "write data within MaxBodyLength", + data: []byte("testdata"), + writer: &mockWriterExcess{writeError: errMockDefault}, + }, + { + name: "write data exceeds MaxBodyLength", + data: []byte("verylongtestdataverylongtestdataverylongtestdata" + + "verylongtestdataverylongtestdataverylongtestdata"), + writer: &mockWriterExcess{writeError: errMockDefault}, + }, + { + name: "write data exceeds MaxBodyLength with newline", + data: []byte("verylongtestdataverylongtestdataverylongtestdata" + + "verylongtestdataverylongtestdataverylongtestdata"), + writer: &mockWriterNewline{writeError: errMockDefault}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lineBreaker := &Base64LineBreaker{out: tt.writer} + + _, err := lineBreaker.Write(tt.data) + if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) { + t.Errorf("unexpected error while writing to mock writer: %s", err) + } + err = lineBreaker.Close() + if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) { + t.Errorf("unexpected error while closing mock writer: %s", err) + } + }) + } + }) } -// removeNewLines removes any newline characters from the given data -func removeNewLines(data []byte) []byte { +// removeNewLines is a test helper thatremoves all newline characters ('\r' and '\n') from the given byte slice. +func removeNewLines(t *testing.T, data []byte) []byte { + t.Helper() result := make([]byte, len(data)) n := 0 @@ -503,11 +181,11 @@ func removeNewLines(data []byte) []byte { type errorWriter struct{} func (e errorWriter) Write([]byte) (int, error) { - return 0, fmt.Errorf("supposed to always fail") + return 0, errClosedWriter } func (e errorWriter) Close() error { - return fmt.Errorf("supposed to always fail") + return errClosedWriter } type mockWriterExcess struct { @@ -539,19 +217,49 @@ func (w *mockWriterNewline) Write(p []byte) (n int, err error) { } } -func FuzzBase64LineBreaker_Write(f *testing.F) { - f.Add([]byte("abc")) - f.Add([]byte("def")) - f.Add([]uint8{0o0, 0o1, 0o2, 30, 255}) - buf := bytes.Buffer{} - bw := bufio.NewWriter(&buf) +func FuzzBase64LineBreaker(f *testing.F) { + seedData := [][]byte{ + []byte(""), + []byte("abc"), + []byte("def"), + []byte("Hello, World!"), + []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!§$%&/()=?`{[]}\\|^~*+#-._'"), + []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"), + bytes.Repeat([]byte("A"), MaxBodyLength-1), // Near the line length limit + bytes.Repeat([]byte("A"), MaxBodyLength), // Exactly the line length limit + bytes.Repeat([]byte("A"), MaxBodyLength+1), // Slightly above the line length limit + bytes.Repeat([]byte("A"), MaxBodyLength*3), // Tripple exceed the line length limit + bytes.Repeat([]byte("A"), MaxBodyLength*10), // Tenfold exceed the line length limit + {0o0, 0o1, 0o2, 30, 255}, + } + for _, data := range seedData { + f.Add(data) + } + f.Fuzz(func(t *testing.T, data []byte) { - b := &Base64LineBreaker{out: bw} - if _, err := b.Write(data); err != nil { - t.Errorf("failed to write to B64LineBreaker: %s", err) + buffer := bytes.NewBuffer(nil) + lineBreaker := &Base64LineBreaker{ + out: buffer, } - if err := b.Close(); err != nil { - t.Errorf("failed to close B64LineBreaker: %s", err) + base64Encoder := base64.NewEncoder(base64.StdEncoding, lineBreaker) + + _, err := base64Encoder.Write(data) + if err != nil { + t.Errorf("failed to write test data to base64 encoder: %s", err) + } + if err = base64Encoder.Close(); err != nil { + t.Errorf("failed to close base64 encoder: %s", err) + } + if err = lineBreaker.Close(); err != nil { + t.Errorf("failed to close base64 line breaker: %s", err) + } + + decode, err := base64.StdEncoding.DecodeString(buffer.String()) + if err != nil { + t.Errorf("failed to decode base64 data: %s", err) + } + if !bytes.Equal(data, decode) { + t.Error("generated line breaker output differs from original data") } }) } diff --git a/client.go b/client.go index 3f36a94..abb90f4 100644 --- a/client.go +++ b/client.go @@ -242,6 +242,12 @@ var ( // provided as argument to the WithDSN Option. ErrInvalidDSNRcptNotifyCombination = errors.New("DSN rcpt notify option NEVER cannot be " + "combined with any of SUCCESS, FAILURE or DELAY") + + // ErrSMTPAuthMethodIsNil indicates that the SMTP authentication method provided is nil + ErrSMTPAuthMethodIsNil = errors.New("SMTP auth method is nil") + + // ErrDialContextFuncIsNil indicates that a required dial context function is not provided. + ErrDialContextFuncIsNil = errors.New("dial context function is nil") ) // NewClient creates a new Client instance with the provided host and optional configuration Option functions. @@ -510,6 +516,9 @@ func WithSMTPAuth(authtype SMTPAuthType) Option { // - An Option function that sets the custom SMTP authentication for the Client. func WithSMTPAuthCustom(smtpAuth smtp.Auth) Option { return func(c *Client) error { + if smtpAuth == nil { + return ErrSMTPAuthMethodIsNil + } c.smtpAuth = smtpAuth c.smtpAuthType = SMTPAuthCustom return nil @@ -671,6 +680,9 @@ func WithoutNoop() Option { // - An Option function that sets the custom DialContextFunc for the Client. func WithDialContextFunc(dialCtxFunc DialContextFunc) Option { return func(c *Client) error { + if dialCtxFunc == nil { + return ErrDialContextFuncIsNil + } c.dialContextFunc = dialCtxFunc return nil } @@ -739,6 +751,7 @@ func (c *Client) SetTLSPolicy(policy TLSPolicy) { func (c *Client) SetTLSPortPolicy(policy TLSPolicy) { if c.port == DefaultPort { c.port = DefaultPortTLS + c.fallbackPort = 0 if policy == TLSOpportunistic { c.fallbackPort = DefaultPort @@ -1081,10 +1094,6 @@ func (c *Client) DialAndSendWithContext(ctx context.Context, messages ...*Msg) e // - An error if the connection check fails, if no supported authentication method is found, // or if the authentication process fails. func (c *Client) auth() error { - if err := c.checkConn(); err != nil { - return fmt.Errorf("failed to authenticate: %w", err) - } - if c.smtpAuth == nil && c.smtpAuthType != SMTPAuthNoAuth { hasSMTPAuth, smtpAuthType := c.smtpClient.Extension("AUTH") if !hasSMTPAuth { @@ -1252,14 +1261,13 @@ func (c *Client) sendSingleMsg(message *Msg) error { affectedMsg: message, } } - message.isDelivered = true - if err = writer.Close(); err != nil { return &SendError{ Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err), affectedMsg: message, } } + message.isDelivered = true if err = c.Reset(); err != nil { return &SendError{ @@ -1267,12 +1275,6 @@ func (c *Client) sendSingleMsg(message *Msg) error { affectedMsg: message, } } - if err = c.checkConn(); err != nil { - return &SendError{ - Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err), - affectedMsg: message, - } - } return nil } @@ -1289,6 +1291,9 @@ func (c *Client) sendSingleMsg(message *Msg) error { // - An error if there is no active connection, if the NOOP command fails, or if extending // the deadline fails; otherwise, returns nil. func (c *Client) checkConn() error { + if c.smtpClient == nil { + return ErrNoActiveConnection + } if !c.smtpClient.HasConnection() { return ErrNoActiveConnection } @@ -1347,9 +1352,6 @@ func (c *Client) setDefaultHelo() error { // - An error if there is no active connection, if STARTTLS is required but not supported, // or if there are issues during the TLS handshake; otherwise, returns nil. func (c *Client) tls() error { - if !c.smtpClient.HasConnection() { - return ErrNoActiveConnection - } if !c.useSSL && c.tlspolicy != NoTLS { hasStartTLS := false extension, _ := c.smtpClient.Extension("STARTTLS") diff --git a/client_121_test.go b/client_121_test.go new file mode 100644 index 0000000..0c96545 --- /dev/null +++ b/client_121_test.go @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +//go:build go1.21 +// +build go1.21 + +package mail + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/wneessen/go-mail/log" +) + +func TestNewClientNewVersionsOnly(t *testing.T) { + tests := []struct { + name string + option Option + expectFunc func(c *Client) error + shouldfail bool + expectErr *error + }{ + { + "WithLogger log.JSONlog", WithLogger(log.NewJSON(os.Stderr, log.LevelDebug)), + func(c *Client) error { + if c.logger == nil { + return errors.New("failed to set logger. Want logger bug got got nil") + } + loggerType := reflect.TypeOf(c.logger).String() + if loggerType != "*log.JSONlog" { + return fmt.Errorf("failed to set logger. Want logger type: %s, got: %s", + "*log.JSONlog", loggerType) + } + return nil + }, + false, nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(DefaultHost, tt.option) + if !tt.shouldfail && err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if tt.shouldfail && err == nil { + t.Errorf("client creation was supposed to fail, but it didn't") + } + if tt.shouldfail && tt.expectErr != nil { + if !errors.Is(err, *tt.expectErr) { + t.Errorf("error for NewClient mismatch. Expected: %s, got: %s", + *tt.expectErr, err) + } + } + if tt.expectFunc != nil { + if err = tt.expectFunc(client); err != nil { + t.Errorf("NewClient with custom option failed: %s", err) + } + } + }) + } +} + +func TestClient_DialWithContextNewVersionsOnly(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{FeatureSet: featureSet, ListenPort: serverPort}); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + t.Run("connect with full debug logging and auth logging", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + logBuffer := bytes.NewBuffer(nil) + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS), + WithDebugLog(), WithLogAuthData(), WithLogger(log.NewJSON(logBuffer, log.LevelDebug)), + WithSMTPAuth(SMTPAuthPlain), WithUsername("test"), WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + t.Cleanup(func() { + if err = client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + }) + + logs := parseJSONLog(t, logBuffer) + if len(logs.Lines) == 0 { + t.Errorf("failed to enable debug logging, but no logs were found") + } + authFound := false + for _, logline := range logs.Lines { + if strings.EqualFold(logline.Message, "AUTH PLAIN AHRlc3QAcGFzc3dvcmQ=") && + logline.Direction.From == "client" && logline.Direction.To == "server" { + authFound = true + } + } + if !authFound { + t.Errorf("logAuthData not working, no authentication info found in logs") + } + }) +} diff --git a/client_test.go b/client_test.go index 80a5669..664f0fd 100644 --- a/client_test.go +++ b/client_test.go @@ -6,16 +6,20 @@ package mail import ( "bufio" + "bytes" "context" "crypto/tls" + "encoding/json" "errors" "fmt" "io" "net" + "net/mail" "os" - "strconv" + "reflect" "strings" "sync" + "sync/atomic" "testing" "time" @@ -25,2578 +29,3462 @@ import ( const ( // DefaultHost is used as default hostname for the Client - DefaultHost = "localhost" - // TestRcpt is a trash mail address to send test mails to - TestRcpt = "couttifaddebro-1473@yopmail.com" + DefaultHost = "127.0.0.1" // TestServerProto is the protocol used for the simple SMTP test server TestServerProto = "tcp" // TestServerAddr is the address the simple SMTP test server listens on TestServerAddr = "127.0.0.1" // TestServerPortBase is the base port for the simple SMTP test server - TestServerPortBase = 2025 + TestServerPortBase = 12025 + // TestSenderValid is a test sender email address considered valid for sending test emails. + TestSenderValid = "valid-from@domain.tld" + // TestRcptValid is a test recipient email address considered valid for sending test emails. + TestRcptValid = "valid-to@domain.tld" ) -// TestNewClient tests the NewClient() method with its default options +// PortAdder is an atomic counter used to increment port numbers for the test SMTP server instances. +var PortAdder atomic.Int32 + +// localhostCert is a PEM-encoded TLS cert generated from src/crypto/tls: +// +// go run generate_cert.go --rsa-bits 1024 --host 127.0.0.1,::1,example.com \ +// --ca --start-date "Jan 1 00:00:00 1970" --duration=1000000h +var localhostCert = []byte(` +-----BEGIN CERTIFICATE----- +MIICFDCCAX2gAwIBAgIRAK0xjnaPuNDSreeXb+z+0u4wDQYJKoZIhvcNAQELBQAw +EjEQMA4GA1UEChMHQWNtZSBDbzAgFw03MDAxMDEwMDAwMDBaGA8yMDg0MDEyOTE2 +MDAwMFowEjEQMA4GA1UEChMHQWNtZSBDbzCBnzANBgkqhkiG9w0BAQEFAAOBjQAw +gYkCgYEA0nFbQQuOWsjbGtejcpWz153OlziZM4bVjJ9jYruNw5n2Ry6uYQAffhqa +JOInCmmcVe2siJglsyH9aRh6vKiobBbIUXXUU1ABd56ebAzlt0LobLlx7pZEMy30 +LqIi9E6zmL3YvdGzpYlkFRnRrqwEtWYbGBf3znO250S56CCWH2UCAwEAAaNoMGYw +DgYDVR0PAQH/BAQDAgKkMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQF +MAMBAf8wLgYDVR0RBCcwJYILZXhhbXBsZS5jb22HBH8AAAGHEAAAAAAAAAAAAAAA +AAAAAAEwDQYJKoZIhvcNAQELBQADgYEAbZtDS2dVuBYvb+MnolWnCNqvw1w5Gtgi +NmvQQPOMgM3m+oQSCPRTNGSg25e1Qbo7bgQDv8ZTnq8FgOJ/rbkyERw2JckkHpD4 +n4qcK27WkEDBtQFlPihIM8hLIuzWoi/9wygiElTy/tVL3y7fGCvY2/k1KBthtZGF +tN8URjVmyEo= +-----END CERTIFICATE-----`) + +// localhostKey is the private key for localhostCert. +var localhostKey = []byte(testingKey(` +-----BEGIN RSA TESTING KEY----- +MIICXgIBAAKBgQDScVtBC45ayNsa16NylbPXnc6XOJkzhtWMn2Niu43DmfZHLq5h +AB9+Gpok4icKaZxV7ayImCWzIf1pGHq8qKhsFshRddRTUAF3np5sDOW3QuhsuXHu +lkQzLfQuoiL0TrOYvdi90bOliWQVGdGurAS1ZhsYF/fOc7bnRLnoIJYfZQIDAQAB +AoGBAMst7OgpKyFV6c3JwyI/jWqxDySL3caU+RuTTBaodKAUx2ZEmNJIlx9eudLA +kucHvoxsM/eRxlxkhdFxdBcwU6J+zqooTnhu/FE3jhrT1lPrbhfGhyKnUrB0KKMM +VY3IQZyiehpxaeXAwoAou6TbWoTpl9t8ImAqAMY8hlULCUqlAkEA+9+Ry5FSYK/m +542LujIcCaIGoG1/Te6Sxr3hsPagKC2rH20rDLqXwEedSFOpSS0vpzlPAzy/6Rbb +PHTJUhNdwwJBANXkA+TkMdbJI5do9/mn//U0LfrCR9NkcoYohxfKz8JuhgRQxzF2 +6jpo3q7CdTuuRixLWVfeJzcrAyNrVcBq87cCQFkTCtOMNC7fZnCTPUv+9q1tcJyB +vNjJu3yvoEZeIeuzouX9TJE21/33FaeDdsXbRhQEj23cqR38qFHsF1qAYNMCQQDP +QXLEiJoClkR2orAmqjPLVhR3t2oB3INcnEjLNSq8LHyQEfXyaFfu4U9l5+fRPL2i +jiC0k/9L5dHUsF0XZothAkEA23ddgRs+Id/HxtojqqUT27B8MT/IGNrYsp4DvS/c +qgkeluku4GjxRlDMBuXk94xOBEinUs+p/hwP1Alll80Tpg== +-----END RSA TESTING KEY-----`)) + +// logLine represents a log entry with time, level, message, and direction details. +type logLine struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Message string `json:"msg"` + Direction struct { + From string `json:"from"` + To string `json:"to"` + } `json:"direction"` +} + +type logData struct { + Lines []logLine `json:"lines"` +} + func TestNewClient(t *testing.T) { - host := "mail.example.com" - tests := []struct { - name string - host string - shouldfail bool - }{ - {"Default", "mail.example.com", false}, - {"Empty host should fail", "", true}, - } + t.Run("create new Client", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if client.smtpAuthType != SMTPAuthNoAuth { + t.Errorf("new Client failed. Expected smtpAuthType: %s, got: %s", SMTPAuthNoAuth, + client.smtpAuthType) + } + if client.connTimeout != DefaultTimeout { + t.Errorf("new Client failed. Expected connTimeout: %s, got: %s", DefaultTimeout.String(), + client.connTimeout.String()) + } + if client.host != DefaultHost { + t.Errorf("new Client failed. Expected host: %s, got: %s", DefaultHost, client.host) + } + if client.port != DefaultPort { + t.Errorf("new Client failed. Expected port: %d, got: %d", DefaultPort, client.port) + } + if client.tlsconfig == nil { + t.Fatal("new Client failed. Expected tlsconfig but got nil") + } + if client.tlsconfig.MinVersion != DefaultTLSMinVersion { + t.Errorf("new Client failed. Expected tlsconfig min TLS version: %d, got: %d", + DefaultTLSMinVersion, client.tlsconfig.MinVersion) + } + if client.tlsconfig.ServerName != DefaultHost { + t.Errorf("new Client failed. Expected tlsconfig server name: %s, got: %s", + DefaultHost, client.tlsconfig.ServerName) + } + if client.tlspolicy != DefaultTLSPolicy { + t.Errorf("new Client failed. Expected tlsconfig policy: %s, got: %s", DefaultTLSPolicy, + client.tlspolicy) + } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(tt.host) - if err != nil && !tt.shouldfail { - t.Errorf("failed to create new client: %s", err) - return - } - if c.host != tt.host { - t.Errorf("failed to create new client. Host expected: %s, got: %s", host, c.host) - } - if c.connTimeout != DefaultTimeout { - t.Errorf("failed to create new client. Timeout expected: %s, got: %s", DefaultTimeout.String(), - c.connTimeout.String()) - } - if c.port != DefaultPort { - t.Errorf("failed to create new client. Port expected: %d, got: %d", DefaultPort, c.port) - } - if c.tlspolicy != TLSMandatory { - t.Errorf("failed to create new client. TLS policy expected: %d, got: %d", TLSMandatory, c.tlspolicy) - } - if c.tlsconfig.ServerName != tt.host { - t.Errorf("failed to create new client. TLS config host expected: %s, got: %s", - host, c.tlsconfig.ServerName) - } - if c.tlsconfig.MinVersion != DefaultTLSMinVersion { - t.Errorf("failed to create new client. TLS config min versino expected: %d, got: %d", - DefaultTLSMinVersion, c.tlsconfig.MinVersion) - } - if c.ServerAddr() != fmt.Sprintf("%s:%d", tt.host, c.port) { - t.Errorf("failed to create new client. c.ServerAddr() expected: %s, got: %s", - fmt.Sprintf("%s:%d", tt.host, c.port), c.ServerAddr()) - } - }) - } + hostname, err := os.Hostname() + if err != nil { + t.Fatalf("failed to get hostname: %s", err) + } + if client.helo != hostname { + t.Errorf("new Client failed. Expected helo: %s, got: %s", hostname, client.helo) + } + }) + t.Run("NewClient with empty hostname should fail", func(t *testing.T) { + _, err := NewClient("") + if err == nil { + t.Fatalf("NewClient with empty hostname should fail") + } + if !errors.Is(err, ErrNoHostname) { + t.Errorf("NewClient with empty hostname should fail with error: %s, got: %s", ErrNoHostname, err) + } + }) + t.Run("NewClient with option", func(t *testing.T) { + hostname := "mail.example.com" + netDailer := net.Dialer{} + tlsDailer := tls.Dialer{NetDialer: &netDailer, Config: &tls.Config{}} + tests := []struct { + name string + option Option + expectFunc func(c *Client) error + shouldfail bool + expectErr *error + }{ + {"nil option", nil, nil, false, nil}, + { + "WithPort", WithPort(465), + func(c *Client) error { + if c.port != 465 { + return fmt.Errorf("failed to set custom port. Want: %d, got: %d", 465, c.port) + } + return nil + }, + false, nil, + }, + { + "WithPort but too high port number", WithPort(100000), nil, true, + &ErrInvalidPort, + }, + { + "WithTimeout", WithTimeout(time.Second * 100), + func(c *Client) error { + if c.connTimeout != time.Second*100 { + return fmt.Errorf("failed to set custom timeout. Want: %d, got: %d", time.Second*100, + c.connTimeout) + } + return nil + }, + false, nil, + }, + { + "WithTimeout but invalid timeout", WithTimeout(-10), nil, true, + &ErrInvalidTimeout, + }, + { + "WithSSL", WithSSL(), + func(c *Client) error { + if !c.useSSL { + return fmt.Errorf("failed to set useSSL. Want: %t, got: %t", true, c.useSSL) + } + return nil + }, + false, nil, + }, + { + "WithSSLPort with no fallback", WithSSLPort(false), + func(c *Client) error { + if !c.useSSL { + return fmt.Errorf("failed to set useSSL. Want: %t, got: %t", true, c.useSSL) + } + if c.port != 465 { + return fmt.Errorf("failed to set ssl port. Want: %d, got: %d", 465, c.port) + } + if c.fallbackPort != 0 { + return fmt.Errorf("failed to set ssl fallbackport. Want: %d, got: %d", 0, + c.fallbackPort) + } + return nil + }, + false, nil, + }, + { + "WithSSLPort with fallback", WithSSLPort(true), + func(c *Client) error { + if !c.useSSL { + return fmt.Errorf("failed to set useSSL. Want: %t, got: %t", true, c.useSSL) + } + if c.port != 465 { + return fmt.Errorf("failed to set ssl port. Want: %d, got: %d", 465, c.port) + } + if c.fallbackPort != 25 { + return fmt.Errorf("failed to set ssl fallbackport. Want: %d, got: %d", 0, + c.fallbackPort) + } + return nil + }, + false, nil, + }, + { + "WithDebugLog", WithDebugLog(), + func(c *Client) error { + if !c.useDebugLog { + return fmt.Errorf("failed to set enable debug log. Want: %t, got: %t", true, + c.useDebugLog) + } + if c.logAuthData { + return fmt.Errorf("failed to set enable debug log. Want logAuthData: %t, got: %t", true, + c.logAuthData) + } + return nil + }, + false, nil, + }, + { + "WithLogger log.Stdlog", WithLogger(log.New(os.Stderr, log.LevelDebug)), + func(c *Client) error { + if c.logger == nil { + return errors.New("failed to set logger. Want logger bug got got nil") + } + loggerType := reflect.TypeOf(c.logger).String() + if loggerType != "*log.Stdlog" { + return fmt.Errorf("failed to set logger. Want logger type: %s, got: %s", + "*log.Stdlog", loggerType) + } + return nil + }, + false, nil, + }, + { + "WithHELO", WithHELO(hostname), + func(c *Client) error { + if c.helo != hostname { + return fmt.Errorf("failed to set custom HELO. Want: %s, got: %s", hostname, c.helo) + } + return nil + }, + false, nil, + }, + { + "WithHELO fail with empty hostname", WithHELO(""), nil, + true, &ErrInvalidHELO, + }, + { + "WithTLSPolicy TLSMandatory", WithTLSPolicy(TLSMandatory), + func(c *Client) error { + if c.tlspolicy != TLSMandatory { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", TLSMandatory, + c.tlspolicy) + } + return nil + }, + false, nil, + }, + { + "WithTLSPolicy TLSOpportunistic", WithTLSPolicy(TLSOpportunistic), + func(c *Client) error { + if c.tlspolicy != TLSOpportunistic { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", TLSOpportunistic, + c.tlspolicy) + } + return nil + }, + false, nil, + }, + { + "WithTLSPolicy NoTLS", WithTLSPolicy(NoTLS), + func(c *Client) error { + if c.tlspolicy != NoTLS { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", TLSOpportunistic, + c.tlspolicy) + } + return nil + }, + false, nil, + }, + { + "WithTLSPortPolicy TLSMandatory", WithTLSPortPolicy(TLSMandatory), + func(c *Client) error { + if c.tlspolicy != TLSMandatory { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", TLSMandatory, + c.tlspolicy) + } + if c.port != 587 { + return fmt.Errorf("failed to set custom TLS policy. Want port: %d, got: %d", 587, + c.port) + } + if c.fallbackPort != 0 { + return fmt.Errorf("failed to set custom TLS policy. Want fallback port: %d, got: %d", + 0, c.fallbackPort) + } + return nil + }, + false, nil, + }, + { + "WithTLSPortPolicy TLSOpportunistic", WithTLSPortPolicy(TLSOpportunistic), + func(c *Client) error { + if c.tlspolicy != TLSOpportunistic { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", TLSOpportunistic, + c.tlspolicy) + } + if c.port != 587 { + return fmt.Errorf("failed to set custom TLS policy. Want port: %d, got: %d", 587, + c.port) + } + if c.fallbackPort != 25 { + return fmt.Errorf("failed to set custom TLS policy. Want fallback port: %d, got: %d", + 25, c.fallbackPort) + } + return nil + }, + false, nil, + }, + { + "WithTLSPortPolicy NoTLS", WithTLSPortPolicy(NoTLS), + func(c *Client) error { + if c.tlspolicy != NoTLS { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", TLSOpportunistic, + c.tlspolicy) + } + if c.port != 25 { + return fmt.Errorf("failed to set custom TLS policy. Want port: %d, got: %d", 25, + c.port) + } + if c.fallbackPort != 0 { + return fmt.Errorf("failed to set custom TLS policy. Want fallback port: %d, got: %d", + 0, c.fallbackPort) + } + return nil + }, + false, nil, + }, + { + "WithTLSPortPolicy invalid", WithTLSPortPolicy(-1), + func(c *Client) error { + if c.tlspolicy.String() != "UnknownPolicy" { + return fmt.Errorf("failed to set custom TLS policy. Want: %s, got: %s", "UnknownPolicy", + c.tlspolicy) + } + if c.port != 587 { + return fmt.Errorf("failed to set custom TLS policy. Want port: %d, got: %d", 587, + c.port) + } + if c.fallbackPort != 0 { + return fmt.Errorf("failed to set custom TLS policy. Want fallback port: %d, got: %d", + 25, c.fallbackPort) + } + return nil + }, + false, nil, + }, + { + "WithTLSConfig with empty tls.Config", WithTLSConfig(&tls.Config{}), + func(c *Client) error { + if c.tlsconfig == nil { + return errors.New("failed to set custom TLS config. Wanted policy but got nil") + } + return nil + }, + false, nil, + }, + { + "WithTLSConfig with custom tls.Config", WithTLSConfig(&tls.Config{ServerName: hostname}), + func(c *Client) error { + if c.tlsconfig == nil { + return errors.New("failed to set custom TLS config. Wanted policy but got nil") + } + if c.tlsconfig.ServerName != hostname { + return fmt.Errorf("failed to set custom TLS config. Want hostname: %s, got: %s", + hostname, c.tlsconfig.ServerName) + } + return nil + }, + false, nil, + }, + { + "WithTLSConfig with nil", WithTLSConfig(nil), nil, + true, &ErrInvalidTLSConfig, + }, + { + "WithSMTPAuthCustom with PLAIN auth", + WithSMTPAuthCustom(smtp.PlainAuth("", "", "", "", false)), + func(c *Client) error { + if c.smtpAuthType != SMTPAuthCustom { + return fmt.Errorf("failed to set custom SMTP auth method. Want smtp auth type: %s, "+ + "got: %s", SMTPAuthCustom, c.smtpAuthType) + } + if c.smtpAuth == nil { + return errors.New("failed to set custom SMTP auth method. Wanted smtp auth method but" + + " got nil") + } + smtpAuthType := reflect.TypeOf(c.smtpAuth).String() + if smtpAuthType != "*smtp.plainAuth" { + return fmt.Errorf("failed to set custom SMTP auth method. Want smtp auth method of type: %s, "+ + "got: %s", "*smtp.plainAuth", smtpAuthType) + } + return nil + }, + false, nil, + }, + { + "WithSMTPAuthCustom with LOGIN auth", + WithSMTPAuthCustom(smtp.LoginAuth("", "", "", false)), + func(c *Client) error { + if c.smtpAuthType != SMTPAuthCustom { + return fmt.Errorf("failed to set custom SMTP auth method. Want smtp auth type: %s, "+ + "got: %s", SMTPAuthCustom, c.smtpAuthType) + } + if c.smtpAuth == nil { + return errors.New("failed to set custom SMTP auth method. Wanted smtp auth method but" + + " got nil") + } + smtpAuthType := reflect.TypeOf(c.smtpAuth).String() + if smtpAuthType != "*smtp.loginAuth" { + return fmt.Errorf("failed to set custom SMTP auth method. Want smtp auth method of type: %s, "+ + "got: %s", "*smtp.loginAuth", smtpAuthType) + } + return nil + }, + false, nil, + }, + { + "WithSMTPAuthCustom with nil", WithSMTPAuthCustom(nil), nil, + true, &ErrSMTPAuthMethodIsNil, + }, + { + "WithUsername", WithUsername("toni.tester"), + func(c *Client) error { + if c.user != "toni.tester" { + return fmt.Errorf("failed to set username. Want username: %s, got: %s", + "toni.tester", c.user) + } + return nil + }, + false, nil, + }, + { + "WithPassword", WithPassword("sU*p3rS3cr3t"), + func(c *Client) error { + if c.pass != "sU*p3rS3cr3t" { + return fmt.Errorf("failed to set password. Want password: %s, got: %s", + "sU*p3rS3cr3t", c.pass) + } + return nil + }, + false, nil, + }, + { + "WithDSN", WithDSN(), + func(c *Client) error { + if c.requestDSN != true { + return fmt.Errorf("failed to enable DSN. Want requestDSN: %t, got: %t", true, + c.requestDSN) + } + if c.dsnReturnType != DSNMailReturnFull { + return fmt.Errorf("failed to enable DSN. Want dsnReturnType: %s, got: %s", + DSNMailReturnFull, c.dsnReturnType) + } + if len(c.dsnRcptNotifyType) != 2 { + return fmt.Errorf("failed to enable DSN. Want 2 default DSN Rcpt Notify types, got: %d", + len(c.dsnRcptNotifyType)) + } + if c.dsnRcptNotifyType[0] != string(DSNRcptNotifyFailure) { + return fmt.Errorf("failed to enable DSN. Want DSN Rcpt Notify Failure type: %s, got: %s", + string(DSNRcptNotifyFailure), c.dsnRcptNotifyType[0]) + } + if c.dsnRcptNotifyType[1] != string(DSNRcptNotifySuccess) { + return fmt.Errorf("failed to enable DSN. Want DSN Rcpt Notify Success type: %s, got: %s", + string(DSNRcptNotifySuccess), c.dsnRcptNotifyType[1]) + } + return nil + }, + false, nil, + }, + { + "WithDSNMailReturnType DSNMailReturnHeadersOnly", + WithDSNMailReturnType(DSNMailReturnHeadersOnly), + func(c *Client) error { + if c.requestDSN != true { + return fmt.Errorf("failed to enable DSN. Want requestDSN: %t, got: %t", true, + c.requestDSN) + } + if c.dsnReturnType != DSNMailReturnHeadersOnly { + return fmt.Errorf("failed to enable DSN. Want dsnReturnType: %s, got: %s", + DSNMailReturnHeadersOnly, c.dsnReturnType) + } + return nil + }, + false, nil, + }, + { + "WithDSNMailReturnType DSNMailReturnFull", + WithDSNMailReturnType(DSNMailReturnFull), + func(c *Client) error { + if c.requestDSN != true { + return fmt.Errorf("failed to enable DSN. Want requestDSN: %t, got: %t", true, + c.requestDSN) + } + if c.dsnReturnType != DSNMailReturnFull { + return fmt.Errorf("failed to enable DSN. Want dsnReturnType: %s, got: %s", + DSNMailReturnFull, c.dsnReturnType) + } + return nil + }, + false, nil, + }, + { + "WithDSNMailReturnType invalid", WithDSNMailReturnType("invalid"), nil, + true, &ErrInvalidDSNMailReturnOption, + }, + { + "WithDSNRcptNotifyType DSNRcptNotifyNever", + WithDSNRcptNotifyType(DSNRcptNotifyNever), + func(c *Client) error { + if c.requestDSN != true { + return fmt.Errorf("failed to enable DSN. Want requestDSN: %t, got: %t", true, + c.requestDSN) + } + if len(c.dsnRcptNotifyType) != 1 { + return fmt.Errorf("failed to enable DSN. Want 1 DSN Rcpt Notify type, got: %d", + len(c.dsnRcptNotifyType)) + } + if c.dsnRcptNotifyType[0] != string(DSNRcptNotifyNever) { + return fmt.Errorf("failed to enable DSN. Want DSN Rcpt Notify Never type: %s, got: %s", + string(DSNRcptNotifyNever), c.dsnRcptNotifyType[0]) + } + return nil + }, + false, nil, + }, + { + "WithDSNRcptNotifyType DSNRcptNotifySuccess, DSNRcptNotifyFailure", + WithDSNRcptNotifyType(DSNRcptNotifySuccess, DSNRcptNotifyFailure), + func(c *Client) error { + if c.requestDSN != true { + return fmt.Errorf("failed to enable DSN. Want requestDSN: %t, got: %t", true, + c.requestDSN) + } + if len(c.dsnRcptNotifyType) != 2 { + return fmt.Errorf("failed to enable DSN. Want 2 DSN Rcpt Notify type, got: %d", + len(c.dsnRcptNotifyType)) + } + if c.dsnRcptNotifyType[0] != string(DSNRcptNotifySuccess) { + return fmt.Errorf("failed to enable DSN. Want DSN Rcpt Notify Success type: %s, got: %s", + string(DSNRcptNotifySuccess), c.dsnRcptNotifyType[0]) + } + if c.dsnRcptNotifyType[1] != string(DSNRcptNotifyFailure) { + return fmt.Errorf("failed to enable DSN. Want DSN Rcpt Notify Failure type: %s, got: %s", + string(DSNRcptNotifyFailure), c.dsnRcptNotifyType[1]) + } + return nil + }, + false, nil, + }, + { + "WithDSNRcptNotifyType DSNRcptNotifyDelay", + WithDSNRcptNotifyType(DSNRcptNotifyDelay), + func(c *Client) error { + if c.requestDSN != true { + return fmt.Errorf("failed to enable DSN. Want requestDSN: %t, got: %t", true, + c.requestDSN) + } + if len(c.dsnRcptNotifyType) != 1 { + return fmt.Errorf("failed to enable DSN. Want 1 DSN Rcpt Notify type, got: %d", + len(c.dsnRcptNotifyType)) + } + if c.dsnRcptNotifyType[0] != string(DSNRcptNotifyDelay) { + return fmt.Errorf("failed to enable DSN. Want DSN Rcpt Notify Delay type: %s, got: %s", + string(DSNRcptNotifyDelay), c.dsnRcptNotifyType[0]) + } + return nil + }, + false, nil, + }, + { + "WithDSNRcptNotifyType invalid", WithDSNRcptNotifyType("invalid"), nil, + true, &ErrInvalidDSNRcptNotifyOption, + }, + { + "WithDSNRcptNotifyType mix valid and invalid", + WithDSNRcptNotifyType(DSNRcptNotifyDelay, "invalid"), nil, + true, &ErrInvalidDSNRcptNotifyOption, + }, + { + "WithDSNRcptNotifyType mix NEVER with SUCCESS", + WithDSNRcptNotifyType(DSNRcptNotifyNever, DSNRcptNotifySuccess), nil, + true, &ErrInvalidDSNRcptNotifyCombination, + }, + { + "WithDSNRcptNotifyType mix NEVER with FAIL", + WithDSNRcptNotifyType(DSNRcptNotifyNever, DSNRcptNotifyFailure), nil, + true, &ErrInvalidDSNRcptNotifyCombination, + }, + { + "WithDSNRcptNotifyType mix NEVER with DELAY", + WithDSNRcptNotifyType(DSNRcptNotifyNever, DSNRcptNotifyDelay), nil, + true, &ErrInvalidDSNRcptNotifyCombination, + }, + { + "WithoutNoop", WithoutNoop(), + func(c *Client) error { + if !c.noNoop { + return fmt.Errorf("failed to disable Noop. Want noNoop: %t, got: %t", false, c.noNoop) + } + return nil + }, + false, nil, + }, + { + "WithDialContextFunc with net.Dailer", WithDialContextFunc(netDailer.DialContext), + func(c *Client) error { + if c.dialContextFunc == nil { + return errors.New("failed to set dial context func, got: nil") + } + ctxType := reflect.TypeOf(c.dialContextFunc).String() + if ctxType != "mail.DialContextFunc" { + return fmt.Errorf("failed to set dial context func, want: %s, got: %s", + "mail.DialContextFunc", ctxType) + } + return nil + }, + false, nil, + }, + { + "WithDialContextFunc with tls.Dailer", WithDialContextFunc(tlsDailer.DialContext), + func(c *Client) error { + if c.dialContextFunc == nil { + return errors.New("failed to set dial context func, got: nil") + } + ctxType := reflect.TypeOf(c.dialContextFunc).String() + if ctxType != "mail.DialContextFunc" { + return fmt.Errorf("failed to set dial context func, want: %s, got: %s", + "mail.DialContextFunc", ctxType) + } + return nil + }, + false, nil, + }, + { + "WithDialContextFunc with custom dialer", + WithDialContextFunc( + func(ctx context.Context, network, address string) (net.Conn, error) { + return nil, nil + }, + ), + func(c *Client) error { + if c.dialContextFunc == nil { + return errors.New("failed to set dial context func, got: nil") + } + ctxType := reflect.TypeOf(c.dialContextFunc).String() + if ctxType != "mail.DialContextFunc" { + return fmt.Errorf("failed to set dial context func, want: %s, got: %s", + "mail.DialContextFunc", ctxType) + } + return nil + }, + false, nil, + }, + { + "WithDialContextFunc with nil", WithDialContextFunc(nil), nil, + true, &ErrDialContextFuncIsNil, + }, + { + "WithLogAuthData", WithLogAuthData(), + func(c *Client) error { + if !c.logAuthData { + return fmt.Errorf("failed to enable auth data logging. Want logAuthData: %t, got: %t", + true, c.logAuthData) + } + return nil + }, + false, nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(DefaultHost, tt.option) + if !tt.shouldfail && err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if tt.shouldfail && err == nil { + t.Errorf("client creation was supposed to fail, but it didn't") + } + if tt.shouldfail && tt.expectErr != nil { + if !errors.Is(err, *tt.expectErr) { + t.Errorf("error for NewClient mismatch. Expected: %s, got: %s", + *tt.expectErr, err) + } + } + if tt.expectFunc != nil { + if err = tt.expectFunc(client); err != nil { + t.Errorf("NewClient with custom option failed: %s", err) + } + } + }) + } + }) + t.Run("NewClient WithSMTPAuth", func(t *testing.T) { + tests := []struct { + name string + option Option + expected SMTPAuthType + }{ + {"CRAM-MD5", WithSMTPAuth(SMTPAuthCramMD5), SMTPAuthCramMD5}, + {"LOGIN", WithSMTPAuth(SMTPAuthLogin), SMTPAuthLogin}, + {"LOGIN-NOENC", WithSMTPAuth(SMTPAuthLoginNoEnc), SMTPAuthLoginNoEnc}, + {"NOAUTH", WithSMTPAuth(SMTPAuthNoAuth), SMTPAuthNoAuth}, + {"PLAIN", WithSMTPAuth(SMTPAuthPlain), SMTPAuthPlain}, + {"PLAIN-NOENC", WithSMTPAuth(SMTPAuthPlainNoEnc), SMTPAuthPlainNoEnc}, + {"SCRAM-SHA-1", WithSMTPAuth(SMTPAuthSCRAMSHA1), SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", WithSMTPAuth(SMTPAuthSCRAMSHA1PLUS), SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256", WithSMTPAuth(SMTPAuthSCRAMSHA256), SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", WithSMTPAuth(SMTPAuthSCRAMSHA256PLUS), SMTPAuthSCRAMSHA256PLUS}, + {"XOAUTH2", WithSMTPAuth(SMTPAuthXOAUTH2), SMTPAuthXOAUTH2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(DefaultHost, tt.option) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if client.smtpAuthType != tt.expected { + t.Errorf("failed to set custom SMTP auth type. Want: %s, got: %s", + tt.expected, client.smtpAuthType) + } + }) + } + }) } -// TestNewClient tests the NewClient() method with its custom options -func TestNewClientWithOptions(t *testing.T) { - host := "mail.example.com" - tests := []struct { - name string - option Option - shouldfail bool - }{ - {"nil option", nil, true}, - {"WithPort()", WithPort(465), false}, - {"WithPort(); port is too high", WithPort(100000), true}, - {"WithTimeout()", WithTimeout(time.Second * 5), false}, - {"WithTimeout()", WithTimeout(-10), true}, - {"WithSSL()", WithSSL(), false}, - {"WithSSLPort(false)", WithSSLPort(false), false}, - {"WithSSLPort(true)", WithSSLPort(true), false}, - {"WithHELO()", WithHELO(host), false}, - {"WithHELO(); helo is empty", WithHELO(""), true}, - {"WithTLSPolicy()", WithTLSPolicy(TLSOpportunistic), false}, - {"WithTLSPortPolicy()", WithTLSPortPolicy(TLSOpportunistic), false}, - {"WithTLSConfig()", WithTLSConfig(&tls.Config{}), false}, - {"WithTLSConfig(); config is nil", WithTLSConfig(nil), true}, - {"WithSMTPAuth(NoAuth)", WithSMTPAuth(SMTPAuthNoAuth), false}, - {"WithSMTPAuth()", WithSMTPAuth(SMTPAuthLogin), false}, - { - "WithSMTPAuthCustom()", - WithSMTPAuthCustom(smtp.PlainAuth("", "", "", "", false)), - false, - }, - {"WithUsername()", WithUsername("test"), false}, - {"WithPassword()", WithPassword("test"), false}, - {"WithDSN()", WithDSN(), false}, - {"WithDSNMailReturnType()", WithDSNMailReturnType(DSNMailReturnFull), false}, - {"WithDSNMailReturnType() wrong option", WithDSNMailReturnType("FAIL"), true}, - {"WithDSNRcptNotifyType()", WithDSNRcptNotifyType(DSNRcptNotifySuccess), false}, - {"WithDSNRcptNotifyType() wrong option", WithDSNRcptNotifyType("FAIL"), true}, - {"WithoutNoop()", WithoutNoop(), false}, - {"WithDebugLog()", WithDebugLog(), false}, - {"WithLogger()", WithLogger(log.New(os.Stderr, log.LevelDebug)), false}, - {"WithLogger()", WithLogAuthData(), false}, - {"WithDialContextFunc()", WithDialContextFunc(func(ctx context.Context, network, address string) (net.Conn, error) { - return nil, nil - }), false}, - - { - "WithDSNRcptNotifyType() NEVER combination", - WithDSNRcptNotifyType(DSNRcptNotifySuccess, DSNRcptNotifyNever), true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, tt.option, nil) - if err != nil && !tt.shouldfail { - t.Errorf("failed to create new client: %s", err) - return - } - _ = c - }) - } -} - -// TestWithHELO tests the WithHELO() option for the NewClient() method -func TestWithHELO(t *testing.T) { - tests := []struct { - name string - value string - want string - }{ - {"HELO test.de", "test.de", "test.de"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithHELO(tt.value)) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - if c.helo != tt.want { - t.Errorf("failed to set custom HELO. Want: %s, got: %s", tt.want, c.helo) - } - }) - } -} - -// TestWithPort tests the WithPort() option for the NewClient() method -func TestWithPort(t *testing.T) { - tests := []struct { - name string - value int - want int - sf bool - }{ - {"set port to 25", 25, 25, false}, - {"set port to 465", 465, 465, false}, - {"set port to 100000", 100000, 25, true}, - {"set port to -10", -10, 25, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithPort(tt.value)) - if err != nil && !tt.sf { - t.Errorf("failed to create new client: %s", err) - return - } - if c.port != tt.want { - t.Errorf("failed to set custom port. Want: %d, got: %d", tt.want, c.port) - } - }) - } -} - -// TestWithTimeout tests the WithTimeout() option for the NewClient() method -func TestWithTimeout(t *testing.T) { - tests := []struct { - name string - value time.Duration - want time.Duration - sf bool - }{ - {"set timeout to 5s", time.Second * 5, time.Second * 5, false}, - {"set timeout to 30s", time.Second * 30, time.Second * 30, false}, - {"set timeout to 1m", time.Minute, time.Minute, false}, - {"set timeout to 0", 0, DefaultTimeout, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithTimeout(tt.value)) - if err != nil && !tt.sf { - t.Errorf("failed to create new client: %s", err) - return - } - if c.connTimeout != tt.want { - t.Errorf("failed to set custom timeout. Want: %d, got: %d", tt.want, c.connTimeout) - } - }) - } -} - -// TestWithTLSPolicy tests the WithTLSPolicy() option for the NewClient() method -func TestWithTLSPolicy(t *testing.T) { +func TestClient_TLSPolicy(t *testing.T) { + t.Run("WithTLSPolicy fmt.Stringer interface", func(t *testing.T) {}) tests := []struct { name string value TLSPolicy want string - sf bool }{ - {"Policy: TLSMandatory", TLSMandatory, TLSMandatory.String(), false}, - {"Policy: TLSOpportunistic", TLSOpportunistic, TLSOpportunistic.String(), false}, - {"Policy: NoTLS", NoTLS, NoTLS.String(), false}, - {"Policy: Invalid", -1, "UnknownPolicy", true}, + {"TLSMandatory", TLSMandatory, "TLSMandatory"}, + {"TLSOpportunistic", TLSOpportunistic, "TLSOpportunistic"}, + {"NoTLS", NoTLS, "NoTLS"}, + {"Invalid", -1, "UnknownPolicy"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithTLSPolicy(tt.value)) - if err != nil && !tt.sf { - t.Errorf("failed to create new client: %s", err) - return - } - if c.tlspolicy.String() != tt.want { - t.Errorf("failed to set TLSPolicy. Want: %s, got: %s", tt.want, c.tlspolicy) - } - }) - } -} - -// TestWithTLSPortPolicy tests the WithTLSPortPolicy() option for the NewClient() method -func TestWithTLSPortPolicy(t *testing.T) { - tests := []struct { - name string - value TLSPolicy - want string - wantPort int - fbPort int - sf bool - }{ - {"Policy: TLSMandatory", TLSMandatory, TLSMandatory.String(), 587, 0, false}, - {"Policy: TLSOpportunistic", TLSOpportunistic, TLSOpportunistic.String(), 587, 25, false}, - {"Policy: NoTLS", NoTLS, NoTLS.String(), 25, 0, false}, - {"Policy: Invalid", -1, "UnknownPolicy", 587, 0, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithTLSPortPolicy(tt.value)) - if err != nil && !tt.sf { - t.Errorf("failed to create new client: %s", err) - return - } - if c.tlspolicy.String() != tt.want { - t.Errorf("failed to set TLSPortPolicy. Want: %s, got: %s", tt.want, c.tlspolicy) - } - if c.port != tt.wantPort { - t.Errorf("failed to set TLSPortPolicy, wanted port: %d, got: %d", tt.wantPort, c.port) - } - if c.fallbackPort != tt.fbPort { - t.Errorf("failed to set TLSPortPolicy, wanted fallbakc port: %d, got: %d", tt.fbPort, - c.fallbackPort) - } - }) - } -} - -// TestSetTLSPolicy tests the SetTLSPolicy() method for the Client object -func TestSetTLSPolicy(t *testing.T) { - tests := []struct { - name string - value TLSPolicy - want string - sf bool - }{ - {"Policy: TLSMandatory", TLSMandatory, TLSMandatory.String(), false}, - {"Policy: TLSOpportunistic", TLSOpportunistic, TLSOpportunistic.String(), false}, - {"Policy: NoTLS", NoTLS, NoTLS.String(), false}, - {"Policy: Invalid", -1, "UnknownPolicy", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS)) + client, err := NewClient(DefaultHost, WithTLSPolicy(tt.value)) if err != nil { - t.Errorf("failed to create new client: %s", err) - return + t.Fatalf("failed to create new client: %s", err) } - c.SetTLSPolicy(tt.value) - if c.tlspolicy.String() != tt.want { - t.Errorf("failed to set TLSPolicy. Want: %s, got: %s", tt.want, c.tlspolicy) + got := client.TLSPolicy() + if !strings.EqualFold(got, tt.want) { + t.Errorf("failed to get expected TLS policy string. Want: %s, got: %s", tt.want, got) } }) } } -// TestSetTLSConfig tests the SetTLSConfig() method for the Client object -func TestSetTLSConfig(t *testing.T) { - tests := []struct { - name string - value *tls.Config - sf bool - }{ - {"default config", &tls.Config{}, false}, - {"nil config", nil, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - if err := c.SetTLSConfig(tt.value); err != nil && !tt.sf { - t.Errorf("failed to set TLSConfig: %s", err) - return - } - }) - } +func TestClient_ServerAddr(t *testing.T) { + t.Run("ServerAddr of default client", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + got := client.ServerAddr() + expected := fmt.Sprintf("%s:%d", DefaultHost, DefaultPort) + if !strings.EqualFold(expected, got) { + t.Errorf("failed to get expected server address. Want: %s, got: %s", expected, got) + } + }) + t.Run("ServerAddr of with custom port", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithPort(587)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + got := client.ServerAddr() + expected := fmt.Sprintf("%s:%d", DefaultHost, 587) + if !strings.EqualFold(expected, got) { + t.Errorf("failed to get expected server address. Want: %s, got: %s", expected, got) + } + }) + t.Run("ServerAddr of with port policy TLSMandatory", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithTLSPortPolicy(TLSMandatory)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + got := client.ServerAddr() + expected := fmt.Sprintf("%s:%d", DefaultHost, 587) + if !strings.EqualFold(expected, got) { + t.Errorf("failed to get expected server address. Want: %s, got: %s", expected, got) + } + }) + t.Run("ServerAddr of with port policy TLSOpportunistic", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithTLSPortPolicy(TLSOpportunistic)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + got := client.ServerAddr() + expected := fmt.Sprintf("%s:%d", DefaultHost, 587) + if !strings.EqualFold(expected, got) { + t.Errorf("failed to get expected server address. Want: %s, got: %s", expected, got) + } + }) + t.Run("ServerAddr of with port policy NoTLS", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithTLSPortPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + got := client.ServerAddr() + expected := fmt.Sprintf("%s:%d", DefaultHost, 25) + if !strings.EqualFold(expected, got) { + t.Errorf("failed to get expected server address. Want: %s, got: %s", expected, got) + } + }) + t.Run("ServerAddr of with SSL", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithSSLPort(false)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + got := client.ServerAddr() + expected := fmt.Sprintf("%s:%d", DefaultHost, 465) + if !strings.EqualFold(expected, got) { + t.Errorf("failed to get expected server address. Want: %s, got: %s", expected, got) + } + }) } -// TestSetSSL tests the SetSSL() method for the Client object -func TestSetSSL(t *testing.T) { - tests := []struct { - name string - value bool - }{ - {"SSL: on", true}, - {"SSL: off", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - c.SetSSL(tt.value) - if c.useSSL != tt.value { - t.Errorf("failed to set SSL setting. Got: %t, want: %t", c.useSSL, tt.value) - } - }) - } +func TestClient_SetTLSPolicy(t *testing.T) { + t.Run("SetTLSPolicy TLSMandatory", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPolicy(TLSMandatory) + if client.tlspolicy != TLSMandatory { + t.Errorf("failed to set expected TLS policy. Want: %s, got: %s", + TLSMandatory, client.tlspolicy) + } + }) + t.Run("SetTLSPolicy TLSOpportunistic", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPolicy(TLSOpportunistic) + if client.tlspolicy != TLSOpportunistic { + t.Errorf("failed to set expected TLS policy. Want: %s, got: %s", + TLSOpportunistic, client.tlspolicy) + } + }) + t.Run("SetTLSPolicy NoTLS", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPolicy(NoTLS) + if client.tlspolicy != NoTLS { + t.Errorf("failed to set expected TLS policy. Want: %s, got: %s", + NoTLS, client.tlspolicy) + } + }) + t.Run("SetTLSPolicy to override WithTLSPolicy", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithTLSPolicy(TLSOpportunistic)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPolicy(TLSMandatory) + if client.tlspolicy != TLSMandatory { + t.Errorf("failed to set expected TLS policy. Want: %s, got: %s", + TLSMandatory, client.tlspolicy) + } + }) +} + +func TestClient_SetTLSPortPolicy(t *testing.T) { + t.Run("SetTLSPortPolicy TLSMandatory", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPortPolicy(TLSMandatory) + if client.tlspolicy != TLSMandatory { + t.Errorf("failed to set expected TLS policy. Want policy: %s, got: %s", + TLSMandatory, client.tlspolicy) + } + if client.port != 587 { + t.Errorf("failed to set expected TLS policy. Want port: %d, got: %d", 587, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected TLS policy. Want fallback port: %d, got: %d", 0, + client.fallbackPort) + } + }) + t.Run("SetTLSPortPolicy TLSOpportunistic", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPortPolicy(TLSOpportunistic) + if client.tlspolicy != TLSOpportunistic { + t.Errorf("failed to set expected TLS policy. Want policy: %s, got: %s", + TLSOpportunistic, client.tlspolicy) + } + if client.port != 587 { + t.Errorf("failed to set expected TLS policy. Want port: %d, got: %d", 587, client.port) + } + if client.fallbackPort != 25 { + t.Errorf("failed to set expected TLS policy. Want fallback port: %d, got: %d", 25, + client.fallbackPort) + } + }) + t.Run("SetTLSPortPolicy NoTLS", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPortPolicy(NoTLS) + if client.tlspolicy != NoTLS { + t.Errorf("failed to set expected TLS policy. Want policy: %s, got: %s", + NoTLS, client.tlspolicy) + } + if client.port != 25 { + t.Errorf("failed to set expected TLS policy. Want port: %d, got: %d", 25, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected TLS policy. Want fallback port: %d, got: %d", 0, + client.fallbackPort) + } + }) + t.Run("SetTLSPortPolicy to override WithTLSPortPolicy", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithTLSPortPolicy(TLSOpportunistic), WithPort(25)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetTLSPortPolicy(TLSMandatory) + if client.tlspolicy != TLSMandatory { + t.Errorf("failed to set expected TLS policy. Want policy: %s, got: %s", + TLSMandatory, client.tlspolicy) + } + if client.port != 587 { + t.Errorf("failed to set expected TLS policy. Want port: %d, got: %d", 587, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected TLS policy. Want fallback port: %d, got: %d", 0, + client.fallbackPort) + } + }) +} + +func TestClient_SetSSL(t *testing.T) { + t.Run("SetSSL true", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSL(true) + if !client.useSSL { + t.Errorf("failed to set expected useSSL: %t", true) + } + }) + t.Run("SetSSL false", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSL(false) + if client.useSSL { + t.Errorf("failed to set expected useSSL: %t", false) + } + }) + t.Run("SetSSL to override WithSSL", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithSSL()) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSL(false) + if client.useSSL { + t.Errorf("failed to set expected useSSL: %t", false) + } + }) } -// TestSetSSLPort tests the Client.SetSSLPort method func TestClient_SetSSLPort(t *testing.T) { - tests := []struct { - name string - value bool - fb bool - port int - fbPort int - }{ - {"SSL: on, fb: off", true, false, 465, 0}, - {"SSL: on, fb: on", true, true, 465, 25}, - {"SSL: off, fb: off", false, false, 25, 0}, - {"SSL: off, fb: on", false, true, 25, 25}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return + t.Run("SetSSLPort true no fallback", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSLPort(true, false) + if !client.useSSL { + t.Errorf("failed to set expected useSSL: %t", true) + } + if client.port != 465 { + t.Errorf("failed to set expected port: %d, got: %d", 465, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected fallback: %d, got: %d", 0, client.fallbackPort) + } + }) + t.Run("SetSSLPort true with fallback", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSLPort(true, true) + if !client.useSSL { + t.Errorf("failed to set expected useSSL: %t", true) + } + if client.port != 465 { + t.Errorf("failed to set expected port: %d, got: %d", 465, client.port) + } + if client.fallbackPort != 25 { + t.Errorf("failed to set expected fallback: %d, got: %d", 25, client.fallbackPort) + } + }) + t.Run("SetSSLPort false no fallback", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSLPort(false, false) + if client.useSSL { + t.Errorf("failed to set expected useSSL: %t", false) + } + if client.port != 25 { + t.Errorf("failed to set expected port: %d, got: %d", 25, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected fallback: %d, got: %d", 0, client.fallbackPort) + } + }) + t.Run("SetSSLPort false with fallback (makes no sense)", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSLPort(false, true) + if client.useSSL { + t.Errorf("failed to set expected useSSL: %t", false) + } + if client.port != 25 { + t.Errorf("failed to set expected port: %d, got: %d", 25, client.port) + } + if client.fallbackPort != 25 { + t.Errorf("failed to set expected fallback: %d, got: %d", 25, client.fallbackPort) + } + }) + t.Run("SetSSLPort to override WithSSL", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithSSL()) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSLPort(false, false) + if client.useSSL { + t.Errorf("failed to set expected useSSL: %t", false) + } + if client.port != 25 { + t.Errorf("failed to set expected port: %d, got: %d", 25, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected fallback: %d, got: %d", 0, client.fallbackPort) + } + }) + t.Run("SetSSLPort with custom port", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithPort(123)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSSLPort(false, false) + if client.useSSL { + t.Errorf("failed to set expected useSSL: %t", false) + } + if client.port != 123 { + t.Errorf("failed to set expected port: %d, got: %d", 123, client.port) + } + if client.fallbackPort != 0 { + t.Errorf("failed to set expected fallback: %d, got: %d", 0, client.fallbackPort) + } + }) +} + +func TestClient_SetDebugLog(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{FeatureSet: featureSet, ListenPort: serverPort}); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + t.Run("SetDebugLog true", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetDebugLog(true) + if !client.useDebugLog { + t.Errorf("failed to set expected useDebugLog: %t", true) + } + }) + t.Run("SetDebugLog false", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetDebugLog(false) + if client.useDebugLog { + t.Errorf("failed to set expected useDebugLog: %t", false) + } + }) + t.Run("SetDebugLog true with active SMTP client", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + buffer := bytes.NewBuffer(nil) + client.SetLogger(log.New(buffer, log.LevelDebug)) + client.SetDebugLog(true) + + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") } - c.SetSSLPort(tt.value, tt.fb) - if c.useSSL != tt.value { - t.Errorf("failed to set SSL setting. Got: %t, want: %t", c.useSSL, tt.value) - } - if c.port != tt.port { - t.Errorf("failed to set SSLPort, wanted port: %d, got: %d", c.port, tt.port) - } - if c.fallbackPort != tt.fbPort { - t.Errorf("failed to set SSLPort, wanted fallback port: %d, got: %d", c.fallbackPort, - tt.fbPort) + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client to test server: %s", err) } }) - } -} -// TestSetUsername tests the SetUsername method for the Client object -func TestSetUsername(t *testing.T) { - tests := []struct { - name string - value string - want string - sf bool - }{ - {"normal username", "testuser", "testuser", false}, - {"empty username", "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return + if !client.useDebugLog { + t.Errorf("failed to set expected useDebugLog: %t", true) + } + if !strings.Contains(buffer.String(), "DEBUG: C --> S: EHLO") { + t.Errorf("failed to enable debug log. Expected string: %s in log buffer but didn't find it. "+ + "Buffer: %s", "DEBUG: C --> S: EHLO", buffer.String()) + } + }) + t.Run("SetDebugLog false to override WithDebugLog", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS), WithDebugLog()) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + buffer := bytes.NewBuffer(nil) + client.SetLogger(log.New(buffer, log.LevelDebug)) + client.SetDebugLog(false) + + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") } - c.SetUsername(tt.value) - if c.user != tt.want { - t.Errorf("failed to set username. Expected %s, got: %s", tt.want, c.user) + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client to test server: %s", err) } }) - } -} -// TestSetPassword tests the SetPassword method for the Client object -func TestSetPassword(t *testing.T) { - tests := []struct { - name string - value string - want string - sf bool - }{ - {"normal password", "testpass", "testpass", false}, - {"empty password", "", "", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return + if client.useDebugLog { + t.Errorf("failed to set expected useDebugLog: %t", false) + } + if buffer.Len() > 0 { + t.Errorf("failed to disable debug logger. Expected buffer to be empty but got: %d", buffer.Len()) + } + }) + t.Run("SetDebugLog true active SMTP client after dial", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") } - c.SetPassword(tt.value) - if c.pass != tt.want { - t.Errorf("failed to set password. Expected %s, got: %s", tt.want, c.pass) + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client to test server: %s", err) } }) - } + + buffer := bytes.NewBuffer(nil) + client.SetLogger(log.New(buffer, log.LevelDebug)) + client.SetDebugLog(true) + if err = client.smtpClient.Noop(); err != nil { + t.Errorf("failed to send NOOP command: %s", err) + } + + if !client.useDebugLog { + t.Errorf("failed to set expected useDebugLog: %t", true) + } + if !strings.Contains(buffer.String(), "DEBUG: C --> S: NOOP") { + t.Errorf("failed to enable debug log. Expected string: %s in log buffer but didn't find it. "+ + "Buffer: %s", "DEBUG: C --> S: NOOP", buffer.String()) + } + }) } -// TestSetSMTPAuth tests the SetSMTPAuth method for the Client object -func TestSetSMTPAuth(t *testing.T) { - tests := []struct { - name string - value SMTPAuthType - want string - sf bool - }{ - {"SMTPAuth: LOGIN", SMTPAuthLogin, "LOGIN", false}, - {"SMTPAuth: PLAIN", SMTPAuthPlain, "PLAIN", false}, - {"SMTPAuth: CRAM-MD5", SMTPAuthCramMD5, "CRAM-MD5", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - c.SetSMTPAuth(tt.value) - if string(c.smtpAuthType) != tt.want { - t.Errorf("failed to set SMTP auth type. Expected %s, got: %s", tt.want, string(c.smtpAuthType)) - } - }) - } +func TestClient_SetTLSConfig(t *testing.T) { + t.Run("SetTLSConfig with &tls.Config", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.SetTLSConfig(&tls.Config{}); err != nil { + t.Errorf("failed to set expected TLSConfig: %s", err) + } + if client.tlsconfig == nil { + t.Fatalf("failed to set expected TLSConfig. TLSConfig is nil") + } + }) + t.Run("SetTLSConfig with InsecureSkipVerify", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}); err != nil { + t.Errorf("failed to set expected TLSConfig: %s", err) + } + if client.tlsconfig == nil { + t.Fatalf("failed to set expected TLSConfig. TLSConfig is nil") + } + if !client.tlsconfig.InsecureSkipVerify { + t.Errorf("failed to set expected TLSConfig. Expected InsecureSkipVerify: %t, got: %t", true, + client.tlsconfig.InsecureSkipVerify) + } + }) + t.Run("SetTLSConfig with nil should fail", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + err = client.SetTLSConfig(nil) + if err == nil { + t.Errorf("SetTLSConfig with nil should fail") + } + if !errors.Is(err, ErrInvalidTLSConfig) { + t.Errorf("SetTLSConfig was expected to fail with %s, got: %s", ErrInvalidTLSConfig, err) + } + }) } -// TestWithDSN tests the WithDSN method for the Client object -func TestWithDSN(t *testing.T) { - c, err := NewClient(DefaultHost, WithDSN()) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - if !c.requestDSN { - t.Errorf("WithDSN failed. c.requestDSN expected to be: %t, got: %t", true, c.requestDSN) - } - if c.dsnReturnType != DSNMailReturnFull { - t.Errorf("WithDSN failed. c.dsnReturnType expected to be: %s, got: %s", DSNMailReturnFull, - c.dsnReturnType) - } - if c.dsnRcptNotifyType[0] != string(DSNRcptNotifyFailure) { - t.Errorf("WithDSN failed. c.dsnRcptNotifyType[0] expected to be: %s, got: %s", DSNRcptNotifyFailure, - c.dsnRcptNotifyType[0]) - } - if c.dsnRcptNotifyType[1] != string(DSNRcptNotifySuccess) { - t.Errorf("WithDSN failed. c.dsnRcptNotifyType[1] expected to be: %s, got: %s", DSNRcptNotifySuccess, - c.dsnRcptNotifyType[1]) - } +func TestClient_SetUsername(t *testing.T) { + t.Run("SetUsername", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetUsername("toni.tester") + if client.user != "toni.tester" { + t.Errorf("failed to set expected username, want: %s, got: %s", "toni.tester", client.user) + } + }) + t.Run("SetUsername to override WithUsername", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithUsername("toni.tester")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetUsername("tina.tester") + if client.user != "tina.tester" { + t.Errorf("failed to set expected username, want: %s, got: %s", "tina.tester", client.user) + } + }) } -// TestWithDSNMailReturnType tests the WithDSNMailReturnType method for the Client object -func TestWithDSNMailReturnType(t *testing.T) { - tests := []struct { - name string - value DSNMailReturnOption - want string - sf bool - }{ - {"WithDSNMailReturnType: FULL", DSNMailReturnFull, "FULL", false}, - {"WithDSNMailReturnType: HDRS", DSNMailReturnHeadersOnly, "HDRS", false}, - {"WithDSNMailReturnType: INVALID", "INVALID", "", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithDSNMailReturnType(tt.value)) - if err != nil && !tt.sf { - t.Errorf("failed to create new client: %s", err) - return - } - if string(c.dsnReturnType) != tt.want { - t.Errorf("WithDSNMailReturnType failed. Expected %s, got: %s", tt.want, string(c.dsnReturnType)) - } - }) - } +func TestClient_SetPassword(t *testing.T) { + t.Run("SetPassword", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetPassword("sU*perS3crEt") + if client.pass != "sU*perS3crEt" { + t.Errorf("failed to set expected password, want: %s, got: %s", "sU*perS3crEt", client.pass) + } + }) + t.Run("SetPassword to override WithPassword", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithPassword("sU*perS3crEt")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetPassword("Su%perS3crEt") + if client.pass != "Su%perS3crEt" { + t.Errorf("failed to set expected password, want: %s, got: %s", "Su%perS3crEt", client.pass) + } + }) } -// TestWithDSNRcptNotifyType tests the WithDSNRcptNotifyType method for the Client object -func TestWithDSNRcptNotifyType(t *testing.T) { - tests := []struct { - name string - value DSNRcptNotifyOption - want string - sf bool - }{ - {"WithDSNRcptNotifyType: NEVER", DSNRcptNotifyNever, "NEVER", false}, - {"WithDSNRcptNotifyType: SUCCESS", DSNRcptNotifySuccess, "SUCCESS", false}, - {"WithDSNRcptNotifyType: FAILURE", DSNRcptNotifyFailure, "FAILURE", false}, - {"WithDSNRcptNotifyType: DELAY", DSNRcptNotifyDelay, "DELAY", false}, - {"WithDSNRcptNotifyType: INVALID", "INVALID", "", true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost, WithDSNRcptNotifyType(tt.value)) - if err != nil && !tt.sf { - t.Errorf("failed to create new client: %s", err) - return - } - if len(c.dsnRcptNotifyType) <= 0 && !tt.sf { - t.Errorf("WithDSNRcptNotifyType failed. Expected at least one DSNRNType but got none") - } - if !tt.sf && c.dsnRcptNotifyType[0] != tt.want { - t.Errorf("WithDSNRcptNotifyType failed. Expected %s, got: %s", tt.want, c.dsnRcptNotifyType[0]) - } - }) - } +func TestClient_SetSMTPAuth(t *testing.T) { + t.Run("SetSMTPAuth", func(t *testing.T) { + tests := []struct { + name string + auth SMTPAuthType + expected SMTPAuthType + }{ + {"CRAM-MD5", SMTPAuthCramMD5, SMTPAuthCramMD5}, + {"LOGIN", SMTPAuthLogin, SMTPAuthLogin}, + {"LOGIN-NOENC", SMTPAuthLoginNoEnc, SMTPAuthLoginNoEnc}, + {"NOAUTH", SMTPAuthNoAuth, SMTPAuthNoAuth}, + {"PLAIN", SMTPAuthPlain, SMTPAuthPlain}, + {"PLAIN-NOENC", SMTPAuthPlainNoEnc, SMTPAuthPlainNoEnc}, + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1, SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS, SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256, SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS, SMTPAuthSCRAMSHA256PLUS}, + {"XOAUTH2", SMTPAuthXOAUTH2, SMTPAuthXOAUTH2}, + } + + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client.SetSMTPAuth(tt.auth) + if client.smtpAuthType != tt.expected { + t.Errorf("failed to set expected SMTPAuthType, want: %s, got: %s", tt.expected, + client.smtpAuthType) + } + }) + } + }) + t.Run("SetSMTPAuth to override WithSMTPAuth", func(t *testing.T) { + tests := []struct { + name string + auth SMTPAuthType + expected SMTPAuthType + }{ + {"CRAM-MD5", SMTPAuthCramMD5, SMTPAuthCramMD5}, + {"LOGIN", SMTPAuthLogin, SMTPAuthLogin}, + {"LOGIN-NOENC", SMTPAuthLoginNoEnc, SMTPAuthLoginNoEnc}, + {"NOAUTH", SMTPAuthNoAuth, SMTPAuthNoAuth}, + {"PLAIN", SMTPAuthPlain, SMTPAuthPlain}, + {"PLAIN-NOENC", SMTPAuthPlainNoEnc, SMTPAuthPlainNoEnc}, + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1, SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS, SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256, SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS, SMTPAuthSCRAMSHA256PLUS}, + {"XOAUTH2", SMTPAuthXOAUTH2, SMTPAuthXOAUTH2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(DefaultHost, WithSMTPAuth(SMTPAuthLogin)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if client.smtpAuthType != SMTPAuthLogin { + t.Fatalf("failed to create client with LOGIN auth, got: %s", client.smtpAuthType) + } + client.SetSMTPAuth(tt.auth) + if client.smtpAuthType != tt.expected { + t.Errorf("failed to set expected SMTPAuthType, want: %s, got: %s", tt.expected, + client.smtpAuthType) + } + }) + } + }) + t.Run("SetSMTPAuth override custom auth", func(t *testing.T) { + client, err := NewClient(DefaultHost, + WithSMTPAuthCustom(smtp.LoginAuth("", "", "", false))) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if client.smtpAuthType != SMTPAuthCustom { + t.Fatalf("failed to create client with Custom auth, got: %s", client.smtpAuthType) + } + client.SetSMTPAuth(SMTPAuthSCRAMSHA256) + if client.smtpAuthType != SMTPAuthSCRAMSHA256 { + t.Errorf("failed to set expected SMTPAuthType, want: %s, got: %s", SMTPAuthSCRAMSHA256, + client.smtpAuthType) + } + if client.smtpAuth != nil { + t.Errorf("failed to set expected SMTPAuth, want: nil, got: %s", client.smtpAuth) + } + }) } -// TestWithoutNoop tests the WithoutNoop method for the Client object -func TestWithoutNoop(t *testing.T) { - c, err := NewClient(DefaultHost, WithoutNoop()) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - if !c.noNoop { - t.Errorf("WithoutNoop failed. c.noNoop expected to be: %t, got: %t", true, c.noNoop) - } - - c, err = NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - if c.noNoop { - t.Errorf("WithoutNoop failed. c.noNoop expected to be: %t, got: %t", false, c.noNoop) - } +func TestClient_SetSMTPAuthCustom(t *testing.T) { + t.Run("SetSMTPAuthCustom", func(t *testing.T) { + tests := []struct { + name string + authFunc smtp.Auth + want string + }{ + {"CRAM-MD5", smtp.CRAMMD5Auth("", ""), "*smtp.cramMD5Auth"}, + { + "LOGIN", smtp.LoginAuth("", "", "", false), + "*smtp.loginAuth", + }, + { + "LOGIN-NOENC", smtp.LoginAuth("", "", "", true), + "*smtp.loginAuth", + }, + { + "PLAIN", smtp.PlainAuth("", "", "", "", false), + "*smtp.plainAuth", + }, + { + "PLAIN-NOENC", smtp.PlainAuth("", "", "", "", true), + "*smtp.plainAuth", + }, + {"SCRAM-SHA-1", smtp.ScramSHA1Auth("", ""), "*smtp.scramAuth"}, + { + "SCRAM-SHA-1-PLUS", smtp.ScramSHA1PlusAuth("", "", nil), + "*smtp.scramAuth", + }, + {"SCRAM-SHA-256", smtp.ScramSHA256Auth("", ""), "*smtp.scramAuth"}, + { + "SCRAM-SHA-256-PLUS", smtp.ScramSHA256PlusAuth("", "", nil), + "*smtp.scramAuth", + }, + {"XOAUTH2", smtp.XOAuth2Auth("", ""), "*smtp.xoauth2Auth"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetSMTPAuthCustom(tt.authFunc) + if client.smtpAuth == nil { + t.Errorf("failed to set custom SMTP auth, expected auth method but got nil") + } + if client.smtpAuthType != SMTPAuthCustom { + t.Errorf("failed to set custom SMTP auth, want auth type: %s, got: %s", SMTPAuthCustom, + client.smtpAuthType) + } + authType := reflect.TypeOf(client.smtpAuth).String() + if authType != tt.want { + t.Errorf("failed to set custom SMTP auth, expected auth method type: %s, got: %s", + tt.want, authType) + } + }) + } + }) } func TestClient_SetLogAuthData(t *testing.T) { - c, err := NewClient(DefaultHost, WithLogAuthData()) - if err != nil { - t.Errorf("failed to create new client: %s", err) - return - } - if !c.logAuthData { - t.Errorf("WithLogAuthData failed. c.logAuthData expected to be: %t, got: %t", true, - c.logAuthData) - } - c.SetLogAuthData(false) - if c.logAuthData { - t.Errorf("SetLogAuthData failed. c.logAuthData expected to be: %t, got: %t", false, - c.logAuthData) - } + t.Run("SetLogAuthData true", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetLogAuthData(true) + if !client.logAuthData { + t.Errorf("failed to set logAuthData, want: true, got: %t", client.logAuthData) + } + }) + t.Run("SetLogAuthData false", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetLogAuthData(false) + if client.logAuthData { + t.Errorf("failed to set logAuthData, want: false, got: %t", client.logAuthData) + } + }) + t.Run("SetLogAuthData override WithLogAuthData", func(t *testing.T) { + client, err := NewClient(DefaultHost, WithLogAuthData()) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.SetLogAuthData(false) + if client.logAuthData { + t.Errorf("failed to set logAuthData, want: false, got: %t", client.logAuthData) + } + }) } -// TestSetSMTPAuthCustom tests the SetSMTPAuthCustom method for the Client object -func TestSetSMTPAuthCustom(t *testing.T) { - tests := []struct { - name string - value smtp.Auth - want string - sf bool - }{ - {"SMTPAuth: CRAM-MD5", smtp.CRAMMD5Auth("", ""), "CRAM-MD5", false}, - {"SMTPAuth: LOGIN", smtp.LoginAuth("", "", "", false), "LOGIN", false}, - {"SMTPAuth: PLAIN", smtp.PlainAuth("", "", "", "", false), "PLAIN", false}, - } - si := smtp.ServerInfo{TLS: true} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := NewClient(DefaultHost) - if err != nil { - t.Errorf("failed to create new client: %s", err) +func TestClient_Close(t *testing.T) { + t.Run("connect and close the Client", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) return } - c.SetSMTPAuthCustom(tt.value) - if c.smtpAuth == nil { - t.Errorf("failed to set custom SMTP auth method. SMTP Auth method is empty") + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") } - if c.smtpAuthType != SMTPAuthCustom { - t.Errorf("failed to set custom SMTP auth method. SMTP Auth type is not custom: %s", - c.smtpAuthType) + t.Fatalf("failed to connect to the test server: %s", err) + } + if !client.smtpClient.HasConnection() { + t.Fatalf("client has no connection") + } + if err = client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + }) + t.Run("connect and double close the Client", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return } - p, _, err := c.smtpAuth.Start(&si) - if err != nil { - t.Errorf("SMTP Auth Start() method returned error: %s", err) + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") } - if p != tt.want { - t.Errorf("SMTP Auth Start() method is returned proto: %s, expected: %s", p, tt.want) + t.Fatalf("failed to connect to the test server: %s", err) + } + if !client.smtpClient.HasConnection() { + t.Fatalf("client has no connection") + } + if err = client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + }) + t.Run("test server will let close fail", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnQuit: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return } - }) - } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + if !client.smtpClient.HasConnection() { + t.Fatalf("client has no connection") + } + if err = client.Close(); err == nil { + t.Errorf("close was supposed to fail, but didn't") + } + }) } -// TestClient_Close_double tests if a close on an already closed connection causes an error. -func TestClient_Close_double(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - ctx := context.Background() - if err = c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial with context: %s", err) - return - } - if c.smtpClient == nil { - t.Errorf("DialWithContext didn't fail but no SMTP client found.") - return - } - if !c.smtpClient.HasConnection() { - t.Errorf("DialWithContext didn't fail but no connection found.") - } - if err = c.Close(); err != nil { - t.Errorf("failed to close connection: %s", err) - } - if err = c.Close(); err != nil { - t.Errorf("failed 2nd close connection: %s", err) - } -} - -// TestClient_DialWithContext tests the DialWithContext method for the Client object func TestClient_DialWithContext(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - ctx := context.Background() - if err = c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial with context: %s", err) - return - } - if c.smtpClient == nil { - t.Errorf("DialWithContext didn't fail but no SMTP client found.") - return - } - if !c.smtpClient.HasConnection() { - t.Errorf("DialWithContext didn't fail but no connection found.") - } - if err := c.Close(); err != nil { - t.Errorf("failed to close connection: %s", err) - } -} - -// TestClient_DialWithContext_Fallback tests the Client.DialWithContext method with the fallback -// port functionality -func TestClient_DialWithContext_Fallback(t *testing.T) { - c, err := getTestConnectionNoTestPort(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - c.SetTLSPortPolicy(TLSOpportunistic) - c.port = 999 - ctx := context.Background() - if err = c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial with context: %s", err) - return - } - if c.smtpClient == nil { - t.Errorf("DialWithContext didn't fail but no SMTP client found.") - return - } - if !c.smtpClient.HasConnection() { - t.Errorf("DialWithContext didn't fail but no connection found.") - } - if err = c.Close(); err != nil { - t.Errorf("failed to close connection: %s", err) - } - - c.port = 999 - c.fallbackPort = 999 - if err = c.DialWithContext(ctx); err == nil { - t.Error("dial with context was supposed to fail, but didn't") - return - } -} - -// TestClient_DialWithContext_Debug tests the DialWithContext method for the Client object with debug -// logging enabled on the SMTP client -func TestClient_DialWithContext_Debug(t *testing.T) { - c, err := getTestClient(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - ctx := context.Background() - if err = c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial with context: %s", err) - return - } - if c.smtpClient == nil { - t.Errorf("DialWithContext didn't fail but no SMTP client found.") - return - } - if !c.smtpClient.HasConnection() { - t.Errorf("DialWithContext didn't fail but no connection found.") - } - c.SetDebugLog(true) - if err = c.Close(); err != nil { - t.Errorf("failed to close connection: %s", err) - } -} - -// TestClient_DialWithContext_Debug_custom tests the DialWithContext method for the Client -// object with debug logging enabled and a custom logger on the SMTP client -func TestClient_DialWithContext_Debug_custom(t *testing.T) { - c, err := getTestClient(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - ctx := context.Background() - if err = c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial with context: %s", err) - return - } - if c.smtpClient == nil { - t.Errorf("DialWithContext didn't fail but no SMTP client found.") - return - } - if !c.smtpClient.HasConnection() { - t.Errorf("DialWithContext didn't fail but no connection found.") - } - c.SetDebugLog(true) - c.SetLogger(log.New(os.Stderr, log.LevelDebug)) - if err = c.Close(); err != nil { - t.Errorf("failed to close connection: %s", err) - } -} - -// TestClient_DialWithContextInvalidHost tests the DialWithContext method with intentional breaking -// for the Client object -func TestClient_DialWithContextInvalidHost(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - c.host = "invalid.addr" - ctx := context.Background() - if err = c.DialWithContext(ctx); err == nil { - t.Errorf("dial succeeded but was supposed to fail") - return - } -} - -// TestClient_DialWithContextInvalidHELO tests the DialWithContext method with intentional breaking -// for the Client object -func TestClient_DialWithContextInvalidHELO(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - c.helo = "" - ctx := context.Background() - if err = c.DialWithContext(ctx); err == nil { - t.Errorf("dial succeeded but was supposed to fail") - return - } -} - -// TestClient_DialWithContextInvalidAuth tests the DialWithContext method with intentional breaking -// for the Client object -func TestClient_DialWithContextInvalidAuth(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - c.user = "invalid" - c.pass = "invalid" - c.SetSMTPAuthCustom(smtp.LoginAuth("invalid", "invalid", "invalid", false)) - ctx := context.Background() - if err = c.DialWithContext(ctx); err == nil { - t.Errorf("dial succeeded but was supposed to fail") - return - } -} - -// TestClient_checkConn tests the checkConn method with intentional breaking for the Client object -func TestClient_checkConn(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - if err = c.checkConn(); err == nil { - t.Errorf("connCheck() should fail but succeeded") - } -} - -// TestClient_DiealWithContextOptions tests the DialWithContext method plus different options -// for the Client object -func TestClient_DialWithContextOptions(t *testing.T) { - tests := []struct { - name string - wantssl bool - wanttls TLSPolicy - sf bool - }{ - {"Want SSL (should fail)", true, NoTLS, true}, - {"Want Mandatory TLS", false, TLSMandatory, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - if tt.wantssl { - c.SetSSL(true) - } - if tt.wanttls != NoTLS { - c.SetTLSPolicy(tt.wanttls) - } - - ctx := context.Background() - if err = c.DialWithContext(ctx); err != nil && !tt.sf { - t.Errorf("failed to dial with context: %s", err) - return - } - if !tt.sf { - if c.smtpClient == nil && !tt.sf { - t.Errorf("DialWithContext didn't fail but no SMTP client found.") - return - } - if !c.smtpClient.HasConnection() && !tt.sf { - t.Errorf("DialWithContext didn't fail but no connection found.") - return - } - if err = c.Reset(); err != nil { - t.Errorf("failed to reset connection: %s", err) - } - if err = c.Close(); err != nil { - t.Errorf("failed to close connection: %s", err) - } - } - }) - } -} - -// TestClient_DialWithContextOptionDialContextFunc tests the DialWithContext method plus -// use dialContextFunc option for the Client object -func TestClient_DialWithContextOptionDialContextFunc(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - called := false - c.dialContextFunc = func(ctx context.Context, network, address string) (net.Conn, error) { - called = true - return (&net.Dialer{}).DialContext(ctx, network, address) - } - - ctx := context.Background() - if err := c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial with context: %s", err) - return - } - - if called == false { - t.Errorf("dialContextFunc supposed to be called but not called") - } -} - -// TestClient_DialSendClose tests the Dial(), Send() and Close() method of Client -func TestClient_DialSendClose(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To(TestRcpt) - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), time.Second*10) - defer cfn() - if err := c.DialWithContext(ctx); err != nil { - t.Errorf("Dial() failed: %s", err) - } - if err := c.Send(m); err != nil { - t.Errorf("Send() failed: %s", err) - } - if err := c.Close(); err != nil { - t.Errorf("Close() failed: %s", err) - } - if !m.IsDelivered() { - t.Errorf("message should be delivered but is indicated no to") - } -} - -// TestClient_DialAndSendWithContext tests the DialAndSendWithContext() method of Client -func TestClient_DialAndSendWithContext(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To(TestRcpt) - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - - tests := []struct { - name string - to time.Duration - sf bool - }{ - {"Timeout: 100s", time.Second * 100, false}, - {"Timeout: 100ms", time.Millisecond * 100, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), tt.to) - defer cfn() - if err := c.DialAndSendWithContext(ctx, m); err != nil && !tt.sf { - t.Errorf("DialAndSendWithContext() failed: %s", err) - } - }) - } -} - -// TestClient_DialAndSend tests the DialAndSend() method of Client -func TestClient_DialAndSend(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To(TestRcpt) - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - if err := c.DialAndSend(m); err != nil { - t.Errorf("DialAndSend() failed: %s", err) - } -} - -// TestClient_DialAndSendWithDSN tests the DialAndSend() method of Client with DSN enabled -func TestClient_DialAndSendWithDSN(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To(TestRcpt) - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - - c, err := getTestConnectionWithDSN(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - if err := c.DialAndSend(m); err != nil { - t.Errorf("DialAndSend() failed: %s", err) - } -} - -// TestClient_DialSendCloseBroken tests the Dial(), Send() and Close() method of Client with broken settings -func TestClient_DialSendCloseBroken(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - tests := []struct { - name string - from string - to string - closestart bool - closeearly bool - sf bool - }{ - {"Invalid FROM", "foo@foo", TestRcpt, false, false, true}, - {"Invalid TO", os.Getenv("TEST_FROM"), "foo@foo", false, false, true}, - {"No FROM", "", TestRcpt, false, false, true}, - {"No TO", os.Getenv("TEST_FROM"), "", false, false, true}, - {"Close early", os.Getenv("TEST_FROM"), TestRcpt, false, true, true}, - {"Close start", os.Getenv("TEST_FROM"), TestRcpt, true, false, true}, - {"Close start/early", os.Getenv("TEST_FROM"), TestRcpt, true, true, true}, - } - - m := NewMsg(WithEncoding(NoEncoding)) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetAddrHeaderIgnoreInvalid(HeaderFrom, tt.from) - m.SetAddrHeaderIgnoreInvalid(HeaderTo, tt.to) - - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), time.Second*10) - defer cfn() - if err := c.DialWithContext(ctx); err != nil && !tt.sf { - t.Errorf("Dail() failed: %s", err) - return - } - if tt.closestart { - _ = c.smtpClient.Close() - } - if err = c.Send(m); err != nil && !tt.sf { - t.Errorf("Send() failed: %s", err) - return - } - if tt.closeearly { - _ = c.smtpClient.Close() - } - if err = c.Close(); err != nil && !tt.sf { - t.Errorf("Close() failed: %s", err) - return - } - }) - } -} - -// TestClient_DialSendCloseBrokenWithDSN tests the Dial(), Send() and Close() method of Client with -// broken settings and DSN enabled -func TestClient_DialSendCloseBrokenWithDSN(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - tests := []struct { - name string - from string - to string - closestart bool - closeearly bool - sf bool - }{ - {"Invalid FROM", "foo@foo", TestRcpt, false, false, true}, - {"Invalid TO", os.Getenv("TEST_FROM"), "foo@foo", false, false, true}, - {"No FROM", "", TestRcpt, false, false, true}, - {"No TO", os.Getenv("TEST_FROM"), "", false, false, true}, - {"Close early", os.Getenv("TEST_FROM"), TestRcpt, false, true, true}, - {"Close start", os.Getenv("TEST_FROM"), TestRcpt, true, false, true}, - {"Close start/early", os.Getenv("TEST_FROM"), TestRcpt, true, true, true}, - } - - m := NewMsg(WithEncoding(NoEncoding)) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetAddrHeaderIgnoreInvalid(HeaderFrom, tt.from) - m.SetAddrHeaderIgnoreInvalid(HeaderTo, tt.to) - - c, err := getTestConnectionWithDSN(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), time.Second*10) - defer cfn() - if err := c.DialWithContext(ctx); err != nil && !tt.sf { - t.Errorf("Dail() failed: %s", err) - return - } - if tt.closestart { - _ = c.smtpClient.Close() - } - if err = c.Send(m); err != nil && !tt.sf { - t.Errorf("Send() failed: %s", err) - return - } - if tt.closeearly { - _ = c.smtpClient.Close() - } - if err = c.Close(); err != nil && !tt.sf { - t.Errorf("Close() failed: %s", err) - return - } - }) - } -} - -// TestClient_Send_withBrokenRecipient tests the Send() method of Client with a broken and a working recipient -func TestClient_Send_withBrokenRecipient(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - var msgs []*Msg - rcpts := []string{"invalid@domain.tld", TestRcpt, "invalid@address.invalid"} - for _, rcpt := range rcpts { - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To(rcpt) - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - msgs = append(msgs, m) - } - - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cfn() - if err := c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial to sending server: %s", err) - } - if err := c.Send(msgs...); err != nil { - if !strings.Contains(err.Error(), "invalid@domain.tld") || - !strings.Contains(err.Error(), "invalid@address.invalid") { - t.Errorf("sending mails to invalid addresses was supposed to fail but didn't") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{FeatureSet: featureSet, ListenPort: serverPort}); err != nil { + t.Errorf("failed to start test server: %s", err) + return } - if strings.Contains(err.Error(), TestRcpt) { - t.Errorf("sending mail to valid addresses failed: %s", err) - } - } - if err := c.Close(); err != nil { - t.Errorf("failed to close client connection: %s", err) - } -} - -func TestClient_DialWithContext_switchAuth(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - - // We start with no auth explicitly set - client, err := NewClient( - os.Getenv("TEST_HOST"), - WithTLSPortPolicy(TLSMandatory), - ) - defer func() { - _ = client.Close() }() - if err != nil { - t.Errorf("failed to create client: %s", err) - return - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to sending server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close client connection: %s", err) - } + time.Sleep(time.Millisecond * 30) - // We switch to LOGIN auth, which the server supports - client.SetSMTPAuth(SMTPAuthLogin) - client.SetUsername(os.Getenv("TEST_SMTPAUTH_USER")) - client.SetPassword(os.Getenv("TEST_SMTPAUTH_PASS")) - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to sending server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close client connection: %s", err) - } + t.Run("connect and check connection", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) - // We switch to CRAM-MD5, which the server does not support - error expected - client.SetSMTPAuth(SMTPAuthCramMD5) - if err = client.DialWithContext(context.Background()); err == nil { - t.Errorf("expected error when dialing with unsupported auth mechanism, got nil") - return - } - if !errors.Is(err, ErrCramMD5AuthNotSupported) { - t.Errorf("expected dial error: %s, but got: %s", ErrCramMD5AuthNotSupported, err) - } + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + t.Cleanup(func() { + if err = client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + }) + if !client.smtpClient.HasConnection() { + t.Fatalf("client has no connection") + } + }) + t.Run("fail on base port use fallback", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) - // We switch to CUSTOM by providing PLAIN auth as function - the server supports this - client.SetSMTPAuthCustom(smtp.PlainAuth("", os.Getenv("TEST_SMTPAUTH_USER"), - os.Getenv("TEST_SMTPAUTH_PASS"), os.Getenv("TEST_HOST"), false)) - if client.smtpAuthType != SMTPAuthCustom { - t.Errorf("expected auth type to be Custom, got: %s", client.smtpAuthType) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to sending server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close client connection: %s", err) - } + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.port = 12345 + client.fallbackPort = serverPort - // We switch back to explicit no authenticaiton - client.SetSMTPAuth(SMTPAuthNoAuth) - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to sending server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close client connection: %s", err) - } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + t.Cleanup(func() { + if err = client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + }) + if !client.smtpClient.HasConnection() { + t.Fatalf("client has no connection") + } + }) + t.Run("fail on invalid host", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) - // Finally we set an empty string as SMTPAuthType and expect and error. This way we can - // verify that we do not accidentaly skip authentication with an empty string SMTPAuthType - client.SetSMTPAuth("") - if err = client.DialWithContext(context.Background()); err == nil { - t.Errorf("expected error when dialing with empty auth mechanism, got nil") - } + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.host = "invalid.addr" + + if err = client.DialWithContext(ctxDial); err == nil { + t.Errorf("client with invalid host should fail") + } + if client.smtpClient != nil { + t.Errorf("client with invalid host should not have a smtp client") + } + }) + t.Run("fail on invalid HELO", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.helo = "" + + if err = client.DialWithContext(ctxDial); err == nil { + t.Errorf("client with invalid HELO should fail") + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close the client: %s", err) + } + }) + if client.smtpClient == nil { + t.Errorf("client with invalid HELO should still have a smtp client, got nil") + } + if !client.smtpClient.HasConnection() { + t.Errorf("client with invalid HELO should still have a smtp client connection, got nil") + } + }) + t.Run("fail on base port and fallback", func(t *testing.T) { + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + client.port = 12345 + client.fallbackPort = 12346 + + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("connection was supposed to fail, but didn't") + } + if client.smtpClient != nil { + t.Fatalf("client has connection") + } + }) + t.Run("connect should fail on HELO", func(t *testing.T) { + ctxFail, cancelFail := context.WithCancel(ctx) + defer cancelFail() + PortAdder.Add(1) + failServerPort := int(TestServerPortBase + PortAdder.Load()) + failFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxFail, t, &serverProps{ + FailOnHelo: true, + FeatureSet: failFeatureSet, + ListenPort: failServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(failServerPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("connection was supposed to fail, but didn't") + } + if client.smtpClient == nil { + t.Fatalf("client has no smtp client") + } + if !client.smtpClient.HasConnection() { + t.Errorf("client has no connection") + } + }) + t.Run("connect with failing auth", func(t *testing.T) { + ctxAuth, cancelAuth := context.WithCancel(ctx) + defer cancelAuth() + PortAdder.Add(1) + authServerPort := int(TestServerPortBase + PortAdder.Load()) + authFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxAuth, t, &serverProps{ + FailOnAuth: true, + FeatureSet: authFeatureSet, + ListenPort: authServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(authServerPort), WithTLSPolicy(NoTLS), + WithSMTPAuth(SMTPAuthPlain), WithUsername("invalid"), WithPassword("invalid")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("connection was supposed to fail, but didn't") + } + }) + t.Run("connect with STARTTLS", func(t *testing.T) { + ctxTLS, cancelTLS := context.WithCancel(ctx) + defer cancelTLS() + PortAdder.Add(1) + tlsServerPort := int(TestServerPortBase + PortAdder.Load()) + tlsFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxTLS, t, &serverProps{ + FeatureSet: tlsFeatureSet, + ListenPort: tlsServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + client, err := NewClient(DefaultHost, WithPort(tlsServerPort), WithTLSPolicy(TLSMandatory), + WithTLSConfig(tlsConfig), WithSMTPAuth(SMTPAuthPlain), WithUsername("test"), + WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + }) + t.Run("connect with STARTTLS Opportunisticly", func(t *testing.T) { + ctxTLS, cancelTLS := context.WithCancel(ctx) + defer cancelTLS() + PortAdder.Add(1) + tlsServerPort := int(TestServerPortBase + PortAdder.Load()) + tlsFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxTLS, t, &serverProps{ + FeatureSet: tlsFeatureSet, + ListenPort: tlsServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + client, err := NewClient(DefaultHost, WithPort(tlsServerPort), WithTLSPolicy(TLSOpportunistic), + WithTLSConfig(tlsConfig), WithSMTPAuth(SMTPAuthPlain), WithUsername("test"), + WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + }) + t.Run("connect with STARTTLS but fail", func(t *testing.T) { + ctxTLS, cancelTLS := context.WithCancel(ctx) + defer cancelTLS() + PortAdder.Add(1) + tlsServerPort := int(TestServerPortBase + PortAdder.Load()) + tlsFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxTLS, t, &serverProps{ + FailOnSTARTTLS: true, + FeatureSet: tlsFeatureSet, + ListenPort: tlsServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + client, err := NewClient(DefaultHost, WithPort(tlsServerPort), WithTLSPolicy(TLSMandatory), + WithTLSConfig(tlsConfig), WithSMTPAuth(SMTPAuthPlain), WithUsername("test"), + WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("connection was supposed to fail, but didn't") + } + }) + t.Run("want STARTTLS, but server does not support it", func(t *testing.T) { + ctxTLS, cancelTLS := context.WithCancel(ctx) + defer cancelTLS() + PortAdder.Add(1) + tlsServerPort := int(TestServerPortBase + PortAdder.Load()) + tlsFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxTLS, t, &serverProps{ + FeatureSet: tlsFeatureSet, + ListenPort: tlsServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + client, err := NewClient(DefaultHost, WithPort(tlsServerPort), WithTLSPolicy(TLSMandatory), + WithTLSConfig(tlsConfig), WithSMTPAuth(SMTPAuthPlain), WithUsername("test"), + WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("connection was supposed to fail, but didn't") + } + }) + t.Run("connect with SSL", func(t *testing.T) { + ctxSSL, cancelSSL := context.WithCancel(ctx) + defer cancelSSL() + PortAdder.Add(1) + sslServerPort := int(TestServerPortBase + PortAdder.Load()) + sslFeatureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctxSSL, t, &serverProps{ + SSLListener: true, + FeatureSet: sslFeatureSet, + ListenPort: sslServerPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + tlsConfig := &tls.Config{InsecureSkipVerify: true} + client, err := NewClient(DefaultHost, WithPort(sslServerPort), WithSSL(), + WithTLSConfig(tlsConfig), WithSMTPAuth(SMTPAuthPlain), WithUsername("test"), + WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + if err := client.Close(); err != nil { + t.Fatalf("failed to close client: %s", err) + } + }) +} + +func TestClient_Reset(t *testing.T) { + t.Run("reset client", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{FeatureSet: featureSet, ListenPort: serverPort}); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Fatalf("failed to close client: %s", err) + } + }) + if err = client.Reset(); err != nil { + t.Errorf("failed to reset client: %s", err) + } + }) + t.Run("reset should fail on disconnected client", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{FeatureSet: featureSet, ListenPort: serverPort}); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + if err = client.Close(); err != nil { + t.Fatalf("failed to close client: %s", err) + } + if err = client.Reset(); err == nil { + t.Errorf("reset on disconnected client should fail") + } + }) + t.Run("reset with server failure", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnReset: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to the test server: %s", err) + } + t.Cleanup(func() { + if err = client.Close(); err != nil { + t.Fatalf("failed to close client: %s", err) + } + }) + if err = client.Reset(); err == nil { + t.Errorf("reset on disconnected client should fail") + } + }) +} + +func TestClient_DialAndSendWithContext(t *testing.T) { + message := testMessage(t) + t.Run("DialAndSend", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialAndSend(message); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to dial and send: %s", err) + } + }) + t.Run("DialAndSendWithContext", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialAndSendWithContext(ctxDial, message); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to dial and send: %s", err) + } + }) + t.Run("DialAndSendWithContext fail on dial", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnHelo: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialAndSendWithContext(ctxDial, message); err == nil { + t.Errorf("client was supposed to fail on dial") + } + }) + t.Run("DialAndSendWithContext fail on close", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnQuit: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialAndSendWithContext(ctxDial, message); err == nil { + t.Errorf("client was supposed to fail on dial") + } + }) + t.Run("DialAndSendWithContext fail on send", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataClose: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialAndSendWithContext(ctxDial, message); err == nil { + t.Errorf("client was supposed to fail on dial") + } + }) } -// TestClient_auth tests the Dial(), Send() and Close() method of Client with broken settings func TestClient_auth(t *testing.T) { - tests := []struct { - name string - auth SMTPAuthType - sf bool - }{ - {"SMTP AUTH: PLAIN", SMTPAuthPlain, false}, - {"SMTP AUTH: LOGIN", SMTPAuthLogin, false}, - {"SMTP AUTH: CRAM-MD5", SMTPAuthCramMD5, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c, err := getTestConnection(false) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), time.Second*5) - defer cfn() - if err := c.DialWithContext(ctx); err != nil { - t.Errorf("auth() failed: could not Dial() => %s", err) - return - } - c.SetSMTPAuth(tt.auth) - c.SetUsername(os.Getenv("TEST_SMTPAUTH_USER")) - c.SetPassword(os.Getenv("TEST_SMTPAUTH_PASS")) - if err := c.auth(); err != nil && !tt.sf { - t.Errorf("auth() failed: %s", err) - } - if err := c.Close(); err != nil { - t.Errorf("auth() failed: could not Close() => %s", err) - } - }) - } -} - -// TestClient_Send_MsgSendError tests the Client.Send method with a broken recipient and verifies -// that the SendError type works properly -func TestClient_Send_MsgSendError(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - var msgs []*Msg - rcpts := []string{"invalid@domain.tld", "invalid@address.invalid"} - for _, rcpt := range rcpts { - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To(rcpt) - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - msgs = append(msgs, m) - } - - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - ctx, cfn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cfn() - if err := c.DialWithContext(ctx); err != nil { - t.Errorf("failed to dial to sending server: %s", err) - } - if err := c.Send(msgs...); err == nil { - t.Errorf("sending messages with broken recipients was supposed to fail but didn't") - } - if err := c.Close(); err != nil { - t.Errorf("failed to close client connection: %s", err) - } - for _, m := range msgs { - if !m.HasSendError() { - t.Errorf("message was expected to have a send error, but didn't") - } - se := &SendError{Reason: ErrSMTPRcptTo} - if !errors.Is(m.SendError(), se) { - t.Errorf("error mismatch, expected: %s, got: %s", se, m.SendError()) - } - if m.SendErrorIsTemp() { - t.Errorf("message was not expected to be a temporary error, but reported as such") - } - } -} - -// TestClient_DialAndSendWithContext_withSendError tests the Client.DialAndSendWithContext method -// with a broken recipient to make sure that the returned error satisfies the Msg.SendError type -func TestClient_DialAndSendWithContext_withSendError(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - m := NewMsg() - _ = m.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")) - _ = m.To("invalid@domain.tld") - m.Subject(fmt.Sprintf("This is a test mail from go-mail/v%s", VERSION)) - m.SetBulk() - m.SetDate() - m.SetMessageID() - m.SetBodyString(TypeTextPlain, "This is a test mail from the go-mail library") - - c, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - ctx, cfn := context.WithTimeout(context.Background(), DefaultTimeout) - defer cfn() - err = c.DialAndSendWithContext(ctx, m) - if err == nil { - t.Errorf("expected DialAndSendWithContext with broken mail recipient to fail, but didn't") - return - } - var se *SendError - if !errors.As(err, &se) { - t.Errorf("expected *SendError type as returned error, but didn't") - return - } - if se.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if m.IsDelivered() { - t.Errorf("message is indicated to be delivered but shouldn't") - } -} - -func TestClient_SendErrorNoEncoding(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - featureSet := "250-AUTH PLAIN\r\n250-DSN\r\n250 SMTPUTF8" - serverPort := TestServerPortBase + 1 - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("valid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "Test body") - message.SetMessageIDWithValue("this.is.a.message.id") - message.SetEncoding(NoEncoding) - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrNoUnencoded { - t.Errorf("expected ErrNoUnencoded error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - if sendErr.Msg() == nil { - t.Errorf("expected message to be set, but got nil") - } - } - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_SendErrorMailFrom(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 2 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("invalid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "Test body") - message.SetMessageIDWithValue("this.is.a.message.id") - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrSMTPMailFrom { - t.Errorf("expected ErrSMTPMailFrom error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - if sendErr.Msg() == nil { - t.Errorf("expected message to be set, but got nil") - } - } - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_SendErrorMailFromReset(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 3 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("invalid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "Test body") - message.SetMessageIDWithValue("this.is.a.message.id") - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrSMTPMailFrom { - t.Errorf("expected ErrSMTPMailFrom error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - if len(sendErr.errlist) != 2 { - t.Errorf("expected 2 errors, but got %d", len(sendErr.errlist)) - return - } - if !strings.EqualFold(sendErr.errlist[0].Error(), "503 5.1.2 Invalid from: ") { - t.Errorf("expected error: %q, but got %q", - "503 5.1.2 Invalid from: ", sendErr.errlist[0].Error()) - } - if !strings.EqualFold(sendErr.errlist[1].Error(), "500 5.1.2 Error: reset failed") { - t.Errorf("expected error: %q, but got %q", - "500 5.1.2 Error: reset failed", sendErr.errlist[1].Error()) - } - } - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_SendErrorToReset(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 4 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("valid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("invalid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "Test body") - message.SetMessageIDWithValue("this.is.a.message.id") - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrSMTPRcptTo { - t.Errorf("expected ErrSMTPRcptTo error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - if len(sendErr.errlist) != 2 { - t.Errorf("expected 2 errors, but got %d", len(sendErr.errlist)) - return - } - if !strings.EqualFold(sendErr.errlist[0].Error(), "500 5.1.2 Invalid to: ") { - t.Errorf("expected error: %q, but got %q", - "500 5.1.2 Invalid to: ", sendErr.errlist[0].Error()) - } - if !strings.EqualFold(sendErr.errlist[1].Error(), "500 5.1.2 Error: reset failed") { - t.Errorf("expected error: %q, but got %q", - "500 5.1.2 Error: reset failed", sendErr.errlist[1].Error()) - } - } - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_SendErrorDataClose(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 5 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("valid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "DATA close should fail") - message.SetMessageIDWithValue("this.is.a.message.id") - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrSMTPDataClose { - t.Errorf("expected ErrSMTPDataClose error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - } - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_SendErrorDataWrite(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 6 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("valid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "DATA write should fail") - message.SetMessageIDWithValue("this.is.a.message.id") - message.SetGenHeader("X-Test-Header", "DATA write should fail") - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrSMTPDataClose { - t.Errorf("expected ErrSMTPDataClose error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - } -} - -func TestClient_SendErrorReset(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 7 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 300) - - message := NewMsg() - if err := message.From("valid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "Test body") - message.SetMessageIDWithValue("this.is.a.message.id") - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Send(message); err == nil { - t.Error("expected Send() to fail but didn't") - } - - var sendErr *SendError - if !errors.As(err, &sendErr) { - t.Errorf("expected *SendError type as returned error, but got %T", sendErr) - } - if errors.As(err, &sendErr) { - if sendErr.IsTemp() { - t.Errorf("expected permanent error but IsTemp() returned true") - } - if sendErr.Reason != ErrSMTPReset { - t.Errorf("expected ErrSMTPReset error, but got %s", sendErr.Reason) - } - if !strings.EqualFold(sendErr.MessageID(), "") { - t.Errorf("expected message ID: %q, but got %q", "", - sendErr.MessageID()) - } - } - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_DialSendConcurrent_online(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } - - client, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - var messages []*Msg - for i := 0; i < 10; i++ { - message := NewMsg() - if err := message.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To(TestRcpt); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject(fmt.Sprintf("Test subject for mail %d", i)) - message.SetBodyString(TypeTextPlain, fmt.Sprintf("This is the test body of the mail no. %d", i)) - message.SetMessageID() - messages = append(messages, message) - } - - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - - wg := sync.WaitGroup{} - for id, message := range messages { - wg.Add(1) - go func(curMsg *Msg, curID int) { - defer wg.Done() - if goroutineErr := client.Send(curMsg); err != nil { - t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr) - } - }(message, id) - } - wg.Wait() - - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } -} - -func TestClient_DialSendConcurrent_local(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 20 - featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 500) - - client, err := NewClient(TestServerAddr, WithPort(serverPort), - WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - } - - var messages []*Msg - for i := 0; i < 20; i++ { - message := NewMsg() - if err := message.From("valid-from@domain.tld"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - return - } - if err := message.To("valid-to@domain.tld"); err != nil { - t.Errorf("failed to set TO address: %s", err) - return - } - message.Subject("Test subject") - message.SetBodyString(TypeTextPlain, "Test body") - message.SetMessageIDWithValue("this.is.a.message.id") - messages = append(messages, message) - } - - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - - wg := sync.WaitGroup{} - for id, message := range messages { - wg.Add(1) - go func(curMsg *Msg, curID int) { - defer wg.Done() - if goroutineErr := client.Send(curMsg); err != nil { - t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr) - } - }(message, id) - } - wg.Wait() - - if err = client.Close(); err != nil { - t.Logf("failed to close server connection: %s", err) - } -} - -func TestClient_AuthSCRAMSHAX(t *testing.T) { - if os.Getenv("TEST_ONLINE_SCRAM") == "" { - t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") - } - hostname := os.Getenv("TEST_HOST_SCRAM") - username := os.Getenv("TEST_USER_SCRAM") - password := os.Getenv("TEST_PASS_SCRAM") - tests := []struct { name string - authtype SMTPAuthType + authType SMTPAuthType }{ + {"CRAM-MD5", SMTPAuthCramMD5}, + {"LOGIN", SMTPAuthLogin}, + {"LOGIN-NOENC", SMTPAuthLoginNoEnc}, + {"PLAIN", SMTPAuthPlain}, + {"PLAIN-NOENC", SMTPAuthPlainNoEnc}, {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + {"XOAUTH2", SMTPAuthXOAUTH2}, } + tlsConfig := tls.Config{InsecureSkipVerify: true} for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := NewClient(hostname, - WithTLSPortPolicy(TLSMandatory), - WithSMTPAuth(tt.authtype), - WithUsername(username), WithPassword(password)) - if err != nil { - t.Errorf("unable to create new client: %s", err) - return - } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } - }) - } -} - -func TestClient_AuthLoginSuccess(t *testing.T) { - tests := []struct { - name string - featureSet string - }{ - {"default", "250-AUTH LOGIN\r\n250-X-DEFAULT-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, - {"mox server", "250-AUTH LOGIN\r\n250-X-MOX-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, - {"null byte", "250-AUTH LOGIN\r\n250-X-NULLBYTE-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, - {"bogus responses", "250-AUTH LOGIN\r\n250-X-BOGUS-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, - {"empty responses", "250-AUTH LOGIN\r\n250-X-EMPTY-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, - } - - for i, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name+" should succeed", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - - serverPort := TestServerPortBase + 40 + i + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH " + tt.name + "\r\n250-STARTTLS\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" go func() { - if err := simpleSMTPServer(ctx, tt.featureSet, true, serverPort); err != nil { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { t.Errorf("failed to start test server: %s", err) return } }() - time.Sleep(time.Millisecond * 300) + time.Sleep(time.Millisecond * 30) - client, err := NewClient(TestServerAddr, - WithPort(serverPort), - WithTLSPortPolicy(NoTLS), - WithSMTPAuth(SMTPAuthLogin), - WithUsername("toni@tester.com"), - WithPassword("V3ryS3cr3t+")) + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), + WithTLSPolicy(TLSMandatory), WithSMTPAuth(tt.authType), WithTLSConfig(&tlsConfig), + WithUsername("test"), WithPassword("password")) if err != nil { - t.Errorf("unable to create new client: %s", err) + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test service: %s", err) + } + if err := client.Close(); err != nil { + t.Errorf("failed to close client connection: %s", err) + } + }) + t.Run(tt.name+" should fail", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH " + tt.name + "\r\n250-STARTTLS\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnAuth: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), + WithTLSPolicy(TLSMandatory), WithSMTPAuth(tt.authType), WithTLSConfig(&tlsConfig), + WithUsername("test"), WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("client should have failed to connect") + } + }) + t.Run(tt.name+" should fail as unspported", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH UNKNOWN\r\n250-8BITMIME\r\n250-STARTTLS\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), + WithTLSPolicy(TLSMandatory), WithSMTPAuth(tt.authType), WithTLSConfig(&tlsConfig), + WithUsername("test"), WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("client should have failed to connect") + } + }) + } + t.Run("auth is not supported at all", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-STARTTLS\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) return } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } - }) - } -} + }() + time.Sleep(time.Millisecond * 30) -func TestClient_AuthLoginFail(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) - serverPort := TestServerPortBase + 50 - featureSet := "250-AUTH LOGIN\r\n250-X-DEFAULT-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return + client, err := NewClient(DefaultHost, WithPort(serverPort), + WithTLSPolicy(TLSMandatory), WithSMTPAuth(SMTPAuthPlain), WithTLSConfig(&tlsConfig), + WithUsername("test"), WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) } - }() - time.Sleep(time.Millisecond * 300) - - client, err := NewClient(TestServerAddr, - WithPort(serverPort), - WithTLSPortPolicy(NoTLS), - WithSMTPAuth(SMTPAuthLogin), - WithUsername("toni@tester.com"), - WithPassword("InvalidPassword")) - if err != nil { - t.Errorf("unable to create new client: %s", err) - return - } - if err = client.DialWithContext(context.Background()); err == nil { - t.Error("expected to fail to dial to test server, but it succeeded") - } -} - -func TestClient_AuthLoginFail_noTLS(t *testing.T) { - if os.Getenv("TEST_SKIP_ONLINE") != "" { - t.Skipf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") - } - th := os.Getenv("TEST_HOST") - if th == "" { - t.Skipf("no host set. Skipping online tests") - } - tp := 587 - if tps := os.Getenv("TEST_PORT"); tps != "" { - tpi, err := strconv.Atoi(tps) - if err == nil { - tp = tpi + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("client should have failed to connect") } - } - client, err := NewClient(th, WithPort(tp), WithSMTPAuth(SMTPAuthLogin), WithTLSPolicy(NoTLS)) - if err != nil { - t.Errorf("failed to create new client: %s", err) - } - u := os.Getenv("TEST_SMTPAUTH_USER") - if u != "" { - client.SetUsername(u) - } - p := os.Getenv("TEST_SMTPAUTH_PASS") - if p != "" { - client.SetPassword(p) - } - // We don't want to log authentication data in tests - client.SetDebugLog(false) - - if err = client.DialWithContext(context.Background()); err == nil { - t.Error("expected to fail to dial to test server, but it succeeded") - } - if !errors.Is(err, smtp.ErrUnencrypted) { - t.Errorf("expected error to be %s, but got %s", smtp.ErrUnencrypted, err) - } -} - -func TestClient_AuthSCRAMSHAX_fail(t *testing.T) { - if os.Getenv("TEST_ONLINE_SCRAM") == "" { - t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") - } - hostname := os.Getenv("TEST_HOST_SCRAM") - - tests := []struct { - name string - authtype SMTPAuthType - }{ - {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, - {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, - {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, - {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := NewClient(hostname, - WithTLSPortPolicy(TLSMandatory), - WithSMTPAuth(tt.authtype), - WithUsername("invalid"), WithPassword("invalid")) - if err != nil { - t.Errorf("unable to create new client: %s", err) + }) + t.Run("SCRAM-X-PLUS on non TLS connection should fail", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH SCRAM-SHA-256-PLUS\r\n250-8BITMIME\r\n250-STARTTLS\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) return } - if err = client.DialWithContext(context.Background()); err == nil { - t.Errorf("expected error but got nil") - } - }) - } -} + }() + time.Sleep(time.Millisecond * 30) -func TestClient_AuthSCRAMSHAX_unsupported(t *testing.T) { - if os.Getenv("TEST_ALLOW_SEND") == "" { - t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") - } + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) - client, err := getTestConnection(true) - if err != nil { - t.Skipf("failed to create test client: %s. Skipping tests", err) - } - - tests := []struct { - name string - authtype SMTPAuthType - expErr error - }{ - {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1, ErrSCRAMSHA1AuthNotSupported}, - {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS, ErrSCRAMSHA1PLUSAuthNotSupported}, - {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256, ErrSCRAMSHA256AuthNotSupported}, - {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS, ErrSCRAMSHA256PLUSAuthNotSupported}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client.SetSMTPAuth(tt.authtype) - client.SetTLSPolicy(TLSMandatory) - if err = client.DialWithContext(context.Background()); err == nil { - t.Errorf("expected error but got nil") - } - if !errors.Is(err, tt.expErr) { - t.Errorf("expected error %s, but got %s", tt.expErr, err) - } - }) - } -} - -func TestClient_AuthSCRAMSHAXPLUS_tlsexporter(t *testing.T) { - if os.Getenv("TEST_ONLINE_SCRAM") == "" { - t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") - } - hostname := os.Getenv("TEST_HOST_SCRAM") - username := os.Getenv("TEST_USER_SCRAM") - password := os.Getenv("TEST_PASS_SCRAM") - - tests := []struct { - name string - authtype SMTPAuthType - }{ - {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, - {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := NewClient(hostname, - WithTLSPortPolicy(TLSMandatory), - WithSMTPAuth(tt.authtype), - WithUsername(username), WithPassword(password)) - if err != nil { - t.Errorf("unable to create new client: %s", err) + client, err := NewClient(DefaultHost, WithTLSPolicy(NoTLS), WithPort(serverPort), + WithSMTPAuth(SMTPAuthSCRAMSHA256PLUS), WithTLSConfig(&tlsConfig), + WithUsername("test"), WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("client should have failed to connect") + } + }) + t.Run("unknown auth type should fail", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-AUTH UNKNOWN\r\n250-8BITMIME\r\n250-STARTTLS\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) return } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) - } - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) - } - }) - } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), + WithTLSPolicy(TLSMandatory), WithSMTPAuth("UNKNOWN"), WithTLSConfig(&tlsConfig), + WithUsername("test"), WithPassword("password")) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err == nil { + t.Fatalf("client should have failed to connect") + } + }) } -func TestClient_AuthSCRAMSHAXPLUS_tlsunique(t *testing.T) { - if os.Getenv("TEST_ONLINE_SCRAM") == "" { - t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") - } - hostname := os.Getenv("TEST_HOST_SCRAM") - username := os.Getenv("TEST_USER_SCRAM") - password := os.Getenv("TEST_PASS_SCRAM") - tlsConfig := &tls.Config{} - tlsConfig.MaxVersion = tls.VersionTLS12 - tlsConfig.ServerName = hostname - - tests := []struct { - name string - authtype SMTPAuthType - }{ - {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, - {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client, err := NewClient(hostname, - WithTLSPortPolicy(TLSMandatory), - WithTLSConfig(tlsConfig), - WithSMTPAuth(tt.authtype), - WithUsername(username), WithPassword(password)) - if err != nil { - t.Errorf("unable to create new client: %s", err) +func TestClient_Send(t *testing.T) { + message := testMessage(t) + t.Run("connect and send email", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) return } - if err = client.DialWithContext(context.Background()); err != nil { - t.Errorf("failed to dial to test server: %s", err) + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") } - if err = client.Close(); err != nil { - t.Errorf("failed to close server connection: %s", err) + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) } }) - } + if err = client.Send(message); err != nil { + t.Errorf("failed to send email: %s", err) + } + }) + t.Run("send with no connection should fail", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.Send(message); err == nil { + t.Errorf("client should have failed to send email with no connection") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Fatalf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrConnCheck { + t.Errorf("expected ErrConnCheck, got %s", sendErr.Reason) + } + }) + t.Run("concurrent sending on a single client connection", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to close the test server connection due to timeout") + } + t.Errorf("failed to close client: %s", err) + } + }) + + var messages []*Msg + for i := 0; i < 50; i++ { + curMessage := testMessage(t) + curMessage.SetMessageIDWithValue("this.is.a.message.id") + messages = append(messages, curMessage) + } + + wg := sync.WaitGroup{} + for id, curMessage := range messages { + wg.Add(1) + go func(curMsg *Msg, curID int) { + defer wg.Done() + if goroutineErr := client.Send(curMsg); err != nil { + t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr) + } + }(curMessage, id) + } + wg.Wait() + }) } -// getTestConnection takes environment variables to establish a connection to a real -// SMTP server to test all functionality that requires a connection -func getTestConnection(auth bool) (*Client, error) { - if os.Getenv("TEST_SKIP_ONLINE") != "" { - return nil, fmt.Errorf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") - } - th := os.Getenv("TEST_HOST") - if th == "" { - return nil, fmt.Errorf("no TEST_HOST set") - } - tp := 25 - if tps := os.Getenv("TEST_PORT"); tps != "" { - tpi, err := strconv.Atoi(tps) - if err == nil { - tp = tpi +func TestClient_sendSingleMsg(t *testing.T) { + t.Run("connect and send email", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) } - } - sv := false - if sve := os.Getenv("TEST_TLS_SKIP_VERIFY"); sve != "" { - sv = true - } - c, err := NewClient(th, WithPort(tp)) - if err != nil { - return c, err - } - c.tlsconfig.InsecureSkipVerify = sv - if auth { - st := os.Getenv("TEST_SMTPAUTH_TYPE") - if st != "" { - c.SetSMTPAuth(SMTPAuthType(st)) + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) } - u := os.Getenv("TEST_SMTPAUTH_USER") - if u != "" { - c.SetUsername(u) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err != nil { + t.Errorf("failed to send message: %s", err) } - p := os.Getenv("TEST_SMTPAUTH_PASS") - if p != "" { - c.SetPassword(p) + }) + t.Run("server does not support 8BITMIME", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + message.SetEncoding(NoEncoding) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) } - // We don't want to log authentication data in tests - c.SetDebugLog(false) - } - if err = c.DialWithContext(context.Background()); err != nil { - return c, fmt.Errorf("connection to test server failed: %w", err) - } - if err = c.Close(); err != nil { - return c, fmt.Errorf("disconnect from test server failed: %w", err) - } - return c, nil + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + }) + t.Run("fail on invalid sender address", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + message.addrHeader["From"] = []*mail.Address{ + {Name: "invalid", Address: "invalid"}, + } + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrSMTPMailFrom { + t.Errorf("expected ErrSMTPMailFrom, got %s", sendErr.Reason) + } + }) + t.Run("fail with no sender address", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + message.addrHeader["From"] = nil + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrGetSender { + t.Errorf("expected ErrGetSender, got %s", sendErr.Reason) + } + }) + t.Run("fail with no recepient addresses", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + message.addrHeader["To"] = nil + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrGetRcpts { + t.Errorf("expected ErrGetRcpts, got %s", sendErr.Reason) + } + }) + t.Run("connect and send email with DSN", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + SupportDSN: true, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS), WithDSN()) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err != nil { + t.Errorf("failed to send message: %s", err) + } + }) + t.Run("connect and send email but fail on reset", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnReset: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrSMTPReset { + t.Errorf("expected ErrSMTPReset, got %s", sendErr.Reason) + } + }) + t.Run("connect and send email but with mix of valid and invalid rcpts", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnReset: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + message.addrHeader["To"] = append(message.addrHeader["To"], &mail.Address{Name: "invalid", Address: "invalid"}) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrSMTPRcptTo { + t.Errorf("expected ErrSMTPRcptTo, got %s", sendErr.Reason) + } + }) + t.Run("connect and send email but fail on mail to and reset", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnMailFrom: true, + FailOnReset: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrSMTPMailFrom { + t.Errorf("expected ErrSMTPMailFrom, got %s", sendErr.Reason) + } + }) + t.Run("connect and send email but fail on data init", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataInit: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrSMTPData { + t.Errorf("expected ErrSMTPData, got %s", sendErr.Reason) + } + }) + t.Run("connect and send email but fail on data close", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataClose: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + message := testMessage(t) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.sendSingleMsg(message); err == nil { + t.Errorf("client should have failed to send message") + } + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected SendError, got %s", err) + } + if sendErr.Reason != ErrSMTPDataClose { + t.Errorf("expected ErrSMTPDataClose, got %s", sendErr.Reason) + } + }) } -// getTestConnectionNoTestPort takes environment variables (except the port) to establish a -// connection to a real SMTP server to test all functionality that requires a connection -func getTestConnectionNoTestPort(auth bool) (*Client, error) { - if os.Getenv("TEST_SKIP_ONLINE") != "" { - return nil, fmt.Errorf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") - } - th := os.Getenv("TEST_HOST") - if th == "" { - return nil, fmt.Errorf("no TEST_HOST set") - } - sv := false - if sve := os.Getenv("TEST_TLS_SKIP_VERIFY"); sve != "" { - sv = true - } - c, err := NewClient(th) - if err != nil { - return c, err - } - c.tlsconfig.InsecureSkipVerify = sv - if auth { - st := os.Getenv("TEST_SMTPAUTH_TYPE") - if st != "" { - c.SetSMTPAuth(SMTPAuthType(st)) +func TestClient_checkConn(t *testing.T) { + t.Run("connection is alive", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) } - u := os.Getenv("TEST_SMTPAUTH_USER") - if u != "" { - c.SetUsername(u) + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) } - p := os.Getenv("TEST_SMTPAUTH_PASS") - if p != "" { - c.SetPassword(p) + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.checkConn(); err != nil { + t.Errorf("failed to check connection: %s", err) } - // We don't want to log authentication data in tests - c.SetDebugLog(false) - } - if err := c.DialWithContext(context.Background()); err != nil { - return c, fmt.Errorf("connection to test server failed: %w", err) - } - if err := c.Close(); err != nil { - return c, fmt.Errorf("disconnect from test server failed: %w", err) - } - return c, nil + }) + t.Run("connection should fail on noop", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnNoop: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500) + t.Cleanup(cancelDial) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.DialWithContext(ctxDial); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + if err = client.checkConn(); err == nil { + t.Errorf("client should have failed on connection check") + } + if !errors.Is(err, ErrNoActiveConnection) { + t.Errorf("expected ErrNoActiveConnection, got %s", err) + } + }) + t.Run("connection should fail on no connection", func(t *testing.T) { + client, err := NewClient(DefaultHost) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + if err = client.checkConn(); err == nil { + t.Errorf("client should have failed on connection check") + } + if !errors.Is(err, ErrNoActiveConnection) { + t.Errorf("expected ErrNoActiveConnection, got %s", err) + } + }) } -// getTestClient takes environment variables to establish a client without connecting -// to the SMTP server -func getTestClient(auth bool) (*Client, error) { - if os.Getenv("TEST_SKIP_ONLINE") != "" { - return nil, fmt.Errorf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") +// TestClient_onlinetests will perform some additional tests on a actual live mail server. These tests are only +// meant for the CI/CD pipeline and are usually skipped. They can be activated by setting PERFORM_ONLINE_TEST=true +// in the ENV. The normal test suite should provide all the tests needed to cover the full functionality. +func TestClient_onlinetests(t *testing.T) { + if os.Getenv("PERFORM_ONLINE_TEST") != "true" { + t.Skip(`"PERFORM_ONLINE_TEST" env variable is not set to "true". Skipping online tests.`) } - th := os.Getenv("TEST_HOST") - if th == "" { - return nil, fmt.Errorf("no TEST_HOST set") - } - tp := 25 - if tps := os.Getenv("TEST_PORT"); tps != "" { - tpi, err := strconv.Atoi(tps) - if err == nil { - tp = tpi + t.Run("Authentication", func(t *testing.T) { + hostname := os.Getenv("TEST_HOST") + username := os.Getenv("TEST_USER") + password := os.Getenv("TEST_PASS") + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"LOGIN", SMTPAuthLogin}, + {"PLAIN", SMTPAuthPlain}, + {"CRAM-MD5", SMTPAuthCramMD5}, + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, } - } - sv := false - if sve := os.Getenv("TEST_TLS_SKIP_VERIFY"); sve != "" { - sv = true - } - c, err := NewClient(th, WithPort(tp)) - if err != nil { - return c, err - } - c.tlsconfig.InsecureSkipVerify = sv - if auth { - st := os.Getenv("TEST_SMTPAUTH_TYPE") - if st != "" { - c.SetSMTPAuth(SMTPAuthType(st)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Fatalf("unable to create new client: %s", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + if err = client.DialWithContext(ctx); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to dial to test server: %s", err) + } + if err = client.smtpClient.Noop(); err != nil { + t.Errorf("failed to send noop: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close client connection: %s", err) + } + }) } - u := os.Getenv("TEST_SMTPAUTH_USER") - if u != "" { - c.SetUsername(u) + }) + t.Run("SCRAM-SHA-PLUS TLSExporter method (TLS 1.3)", func(t *testing.T) { + hostname := os.Getenv("TEST_HOST") + username := os.Getenv("TEST_USER") + password := os.Getenv("TEST_PASS") + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, } - p := os.Getenv("TEST_SMTPAUTH_PASS") - if p != "" { - c.SetPassword(p) + + tlsConfig := &tls.Config{ + MaxVersion: tls.VersionTLS13, + MinVersion: tls.VersionTLS13, + ServerName: hostname, } - // We don't want to log authentication data in tests - c.SetDebugLog(false) - } - return c, nil + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), WithTLSConfig(tlsConfig), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Fatalf("unable to create new client: %s", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + if err = client.DialWithContext(ctx); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to dial to test server: %s", err) + } + if err = client.smtpClient.Noop(); err != nil { + t.Errorf("failed to send noop: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close client connection: %s", err) + } + }) + } + }) + t.Run("SCRAM-SHA-PLUS TLSUnique method (TLS 1.2)", func(t *testing.T) { + hostname := os.Getenv("TEST_HOST") + username := os.Getenv("TEST_USER") + password := os.Getenv("TEST_PASS") + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + tlsConfig := &tls.Config{ + MaxVersion: tls.VersionTLS12, + MinVersion: tls.VersionTLS12, + ServerName: hostname, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), WithTLSConfig(tlsConfig), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Fatalf("unable to create new client: %s", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + t.Cleanup(cancel) + + if err = client.DialWithContext(ctx); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to dial to test server: %s", err) + } + if err = client.smtpClient.Noop(); err != nil { + t.Errorf("failed to send noop: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close client connection: %s", err) + } + }) + } + }) } -// getTestConnectionWithDSN takes environment variables to establish a connection to a real -// SMTP server to test all functionality that requires a connection. It also enables DSN -func getTestConnectionWithDSN(auth bool) (*Client, error) { - if os.Getenv("TEST_SKIP_ONLINE") != "" { - return nil, fmt.Errorf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") - } - th := os.Getenv("TEST_HOST") - if th == "" { - return nil, fmt.Errorf("no TEST_HOST set") - } - tp := 25 - if tps := os.Getenv("TEST_PORT"); tps != "" { - tpi, err := strconv.Atoi(tps) - if err == nil { - tp = tpi +func TestClient_XOAuth2OnFaker(t *testing.T) { + t.Run("Success", func(t *testing.T) { + server := []string{ + "220 Fake server ready ESMTP", + "250-fake.server", + "250-AUTH LOGIN XOAUTH2", + "250 8BITMIME", + "235 2.7.0 Accepted", + "221 OK", } - } - c, err := NewClient(th, WithDSN(), WithPort(tp)) - if err != nil { - return c, err - } - if auth { - st := os.Getenv("TEST_SMTPAUTH_TYPE") - if st != "" { - c.SetSMTPAuth(SMTPAuthType(st)) + var wrote strings.Builder + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(strings.Join(server, "\r\n")), + &wrote, } - u := os.Getenv("TEST_SMTPAUTH_USER") - if u != "" { - c.SetUsername(u) + c, err := NewClient("fake.host", + WithDialContextFunc(getFakeDialFunc(fake)), + WithTLSPortPolicy(NoTLS), + WithSMTPAuth(SMTPAuthXOAUTH2), + WithUsername("user"), + WithPassword("token")) + if err != nil { + t.Fatalf("unable to create new client: %v", err) } - p := os.Getenv("TEST_SMTPAUTH_PASS") - if p != "" { - c.SetPassword(p) + if err = c.DialWithContext(context.Background()); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("unexpected dial error: %v", err) } - } - if err := c.DialWithContext(context.Background()); err != nil { - return c, fmt.Errorf("connection to test server failed: %w", err) - } - if err := c.Close(); err != nil { - return c, fmt.Errorf("disconnect from test server failed: %w", err) - } - return c, nil -} - -func TestXOAuth2OK(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 30 - featureSet := "250-AUTH XOAUTH2\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 500) - - c, err := NewClient("127.0.0.1", - WithPort(serverPort), - WithTLSPortPolicy(TLSOpportunistic), - WithSMTPAuth(SMTPAuthXOAUTH2), - WithUsername("user"), - WithPassword("token")) - if err != nil { - t.Fatalf("unable to create new client: %v", err) - } - if err = c.DialWithContext(context.Background()); err != nil { - t.Fatalf("unexpected dial error: %v", err) - } - if err = c.Close(); err != nil { - t.Fatalf("disconnect from test server failed: %v", err) - } -} - -func TestXOAuth2Unsupported(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - serverPort := TestServerPortBase + 31 - featureSet := "250-AUTH LOGIN PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" - go func() { - if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { - t.Errorf("failed to start test server: %s", err) - return - } - }() - time.Sleep(time.Millisecond * 500) - - c, err := NewClient("127.0.0.1", - WithPort(serverPort), - WithTLSPolicy(TLSOpportunistic), - WithSMTPAuth(SMTPAuthXOAUTH2), - WithUsername("user"), - WithPassword("token")) - if err != nil { - t.Fatalf("unable to create new client: %v", err) - } - if err = c.DialWithContext(context.Background()); err == nil { - t.Fatal("expected dial error got nil") - } else { - if !errors.Is(err, ErrXOauth2AuthNotSupported) { - t.Fatalf("expected %v; got %v", ErrXOauth2AuthNotSupported, err) - } - } - if err = c.Close(); err != nil { - t.Fatalf("disconnect from test server failed: %v", err) - } -} - -func TestXOAuth2OK_faker(t *testing.T) { - server := []string{ - "220 Fake server ready ESMTP", - "250-fake.server", - "250-AUTH LOGIN XOAUTH2", - "250 8BITMIME", - "250 OK", - "235 2.7.0 Accepted", - "221 OK", - } - var wrote strings.Builder - var fake faker - fake.ReadWriter = struct { - io.Reader - io.Writer - }{ - strings.NewReader(strings.Join(server, "\r\n")), - &wrote, - } - c, err := NewClient("fake.host", - WithDialContextFunc(getFakeDialFunc(fake)), - WithTLSPortPolicy(TLSOpportunistic), - WithSMTPAuth(SMTPAuthXOAUTH2), - WithUsername("user"), - WithPassword("token")) - if err != nil { - t.Fatalf("unable to create new client: %v", err) - } - if err = c.DialWithContext(context.Background()); err != nil { - t.Fatalf("unexpected dial error: %v", err) - } - if err = c.Close(); err != nil { - t.Fatalf("disconnect from test server failed: %v", err) - } - if !strings.Contains(wrote.String(), "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n") { - t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n", wrote.String()) - } -} - -func TestXOAuth2Unsupported_faker(t *testing.T) { - server := []string{ - "220 Fake server ready ESMTP", - "250-fake.server", - "250-AUTH LOGIN PLAIN", - "250 8BITMIME", - "250 OK", - "221 OK", - } - var wrote strings.Builder - var fake faker - fake.ReadWriter = struct { - io.Reader - io.Writer - }{ - strings.NewReader(strings.Join(server, "\r\n")), - &wrote, - } - c, err := NewClient("fake.host", - WithDialContextFunc(getFakeDialFunc(fake)), - WithTLSPortPolicy(TLSOpportunistic), - WithSMTPAuth(SMTPAuthXOAUTH2)) - if err != nil { - t.Fatalf("unable to create new client: %v", err) - } - if err = c.DialWithContext(context.Background()); err == nil { - t.Fatal("expected dial error got nil") - } else { - if !errors.Is(err, ErrXOauth2AuthNotSupported) { - t.Fatalf("expected %v; got %v", ErrXOauth2AuthNotSupported, err) - } - } - if err = c.Close(); err != nil { - t.Fatalf("disconnect from test server failed: %v", err) - } - client := strings.Split(wrote.String(), "\r\n") - if len(client) != 4 { - t.Fatalf("unexpected number of client requests got %d; want 5", len(client)) - } - if !strings.HasPrefix(client[0], "EHLO") { - t.Fatalf("expected EHLO, got %q", client[0]) - } - if client[1] != "NOOP" { - t.Fatalf("expected NOOP, got %q", client[1]) - } - if client[2] != "QUIT" { - t.Fatalf("expected QUIT, got %q", client[3]) - } + if err = c.Close(); err != nil { + t.Fatalf("disconnect from test server failed: %v", err) + } + if !strings.Contains(wrote.String(), "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n") { + t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n", wrote.String()) + } + }) + t.Run("Unsupported", func(t *testing.T) { + server := []string{ + "220 Fake server ready ESMTP", + "250-fake.server", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "221 OK", + } + var wrote strings.Builder + var fake faker + fake.ReadWriter = struct { + io.Reader + io.Writer + }{ + strings.NewReader(strings.Join(server, "\r\n")), + &wrote, + } + c, err := NewClient("fake.host", + WithDialContextFunc(getFakeDialFunc(fake)), + WithTLSPortPolicy(TLSOpportunistic), + WithSMTPAuth(SMTPAuthXOAUTH2)) + if err != nil { + t.Fatalf("unable to create new client: %v", err) + } + if err = c.DialWithContext(context.Background()); err == nil { + t.Fatal("expected dial error got nil") + } else { + if !errors.Is(err, ErrXOauth2AuthNotSupported) { + t.Fatalf("expected %v; got %v", ErrXOauth2AuthNotSupported, err) + } + } + if err = c.Close(); err != nil { + t.Fatalf("disconnect from test server failed: %v", err) + } + client := strings.Split(wrote.String(), "\r\n") + if len(client) != 3 { + t.Fatalf("unexpected number of client requests got %d; want 3", len(client)) + } + if !strings.HasPrefix(client[0], "EHLO") { + t.Fatalf("expected EHLO, got %q", client[0]) + } + if client[1] != "QUIT" { + t.Fatalf("expected QUIT, got %q", client[3]) + } + }) } +// getFakeDialFunc returns a DialContextFunc that always returns the given net.Conn without establishing a +// real network connection. func getFakeDialFunc(conn net.Conn) DialContextFunc { return func(ctx context.Context, network, address string) (net.Conn, error) { return conn, nil } } +// faker is an internal structure that embeds io.ReadWriter to simulate network read/write operations. type faker struct { io.ReadWriter } @@ -2608,19 +3496,108 @@ func (f faker) SetDeadline(time.Time) error { return nil } func (f faker) SetReadDeadline(time.Time) error { return nil } func (f faker) SetWriteDeadline(time.Time) error { return nil } +// parseJSONLog parses a JSON encoded log from the provided buffer and returns a slice of logLine structs. +// In case of a decode error, it reports the error to the testing framework. +func parseJSONLog(t *testing.T, buf *bytes.Buffer) logData { + t.Helper() + + builder := strings.Builder{} + builder.WriteString(`{"lines":[`) + lines := strings.Split(buf.String(), "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + builder.WriteString(line) + if i < len(lines)-2 { + builder.WriteString(`,`) + } + } + builder.WriteString("]}") + + var logdata logData + readBuffer := bytes.NewBuffer(nil) + readBuffer.WriteString(builder.String()) + if err := json.NewDecoder(readBuffer).Decode(&logdata); err != nil { + t.Errorf("failed to decode json log: %s", err) + } + return logdata +} + +// testMessage configures and returns a new email message for testing, initializing it with valid sender and recipient. +func testMessage(t *testing.T, opts ...MsgOption) *Msg { + t.Helper() + message := NewMsg(opts...) + if message == nil { + t.Fatal("failed to create new message") + } + if err := message.From(TestSenderValid); err != nil { + t.Errorf("failed to set sender address: %s", err) + } + if err := message.To(TestRcptValid); err != nil { + t.Errorf("failed to set recipient address: %s", err) + } + message.Subject("Testmail") + message.SetBodyString(TypeTextPlain, "Testmail") + return message +} + +// testingKey replaces the substring "TESTING KEY" with "PRIVATE KEY" in the given string s. +func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } + +// serverProps represents the configuration properties for the SMTP server. +type serverProps struct { + FailOnAuth bool + FailOnDataInit bool + FailOnDataClose bool + FailOnHelo bool + FailOnMailFrom bool + FailOnNoop bool + FailOnQuit bool + FailOnReset bool + FailOnSTARTTLS bool + FailTemp bool + FeatureSet string + ListenPort int + SSLListener bool + IsTLS bool + SupportDSN bool +} + // simpleSMTPServer starts a simple TCP server that resonds to SMTP commands. // The provided featureSet represents in what the server responds to EHLO command // failReset controls if a RSET succeeds -func simpleSMTPServer(ctx context.Context, featureSet string, failReset bool, port int) error { - listener, err := net.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, port)) +func simpleSMTPServer(ctx context.Context, t *testing.T, props *serverProps) error { + t.Helper() + if props == nil { + return fmt.Errorf("no server properties provided") + } + + var listener net.Listener + var err error + if props.SSLListener { + keypair, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + return fmt.Errorf("failed to read TLS keypair: %w", err) + } + tlsConfig := &tls.Config{Certificates: []tls.Certificate{keypair}} + listener, err = tls.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, props.ListenPort), + tlsConfig) + if err != nil { + t.Fatalf("failed to create TLS listener: %s", err) + } + } else { + listener, err = net.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, props.ListenPort)) + } if err != nil { - return fmt.Errorf("unable to listen on %s://%s: %w", TestServerProto, TestServerAddr, err) + return fmt.Errorf("unable to listen on %s://%s: %w (SSL: %t)", TestServerProto, TestServerAddr, err, + props.SSLListener) } defer func() { if err := listener.Close(); err != nil { - fmt.Printf("unable to close listener: %s\n", err) - os.Exit(1) + t.Logf("failed to close listener: %s", err) } }() @@ -2637,51 +3614,41 @@ func simpleSMTPServer(ctx context.Context, featureSet string, failReset bool, po } return fmt.Errorf("unable to accept connection: %w", err) } - handleTestServerConnection(connection, featureSet, failReset) + handleTestServerConnection(connection, t, props) } } } -func handleTestServerConnection(connection net.Conn, featureSet string, failReset bool) { - defer func() { - if err := connection.Close(); err != nil { - fmt.Printf("unable to close connection: %s\n", err) - } - }() +func handleTestServerConnection(connection net.Conn, t *testing.T, props *serverProps) { + t.Helper() + if !props.IsTLS { + t.Cleanup(func() { + if err := connection.Close(); err != nil { + t.Logf("failed to close connection: %s", err) + } + }) + } reader := bufio.NewReader(connection) writer := bufio.NewWriter(connection) - writeLine := func(data string) error { + writeLine := func(data string) { _, err := writer.WriteString(data + "\r\n") if err != nil { - return fmt.Errorf("unable to write line: %w", err) + t.Logf("failed to write line: %s", err) } - return writer.Flush() + _ = writer.Flush() } writeOK := func() { - _ = writeLine("250 2.0.0 OK") + writeLine("250 2.0.0 OK") } - if err := writeLine("220 go-mail test server ready ESMTP"); err != nil { - fmt.Printf("unable to write to client: %s\n", err) - return - } - - data, err := reader.ReadString('\n') - if err != nil { - return - } - if !strings.HasPrefix(data, "EHLO") && !strings.HasPrefix(data, "HELO") { - fmt.Printf("expected EHLO, got %q", data) - return - } - if err = writeLine("250-localhost.localdomain\r\n" + featureSet); err != nil { - return + if !props.IsTLS { + writeLine("220 go-mail test server ready ESMTP") } for { - data, err = reader.ReadString('\n') + data, err := reader.ReadString('\n') if err != nil { break } @@ -2690,119 +3657,115 @@ func handleTestServerConnection(connection net.Conn, featureSet string, failRese var datastring string data = strings.TrimSpace(data) switch { + case strings.HasPrefix(data, "EHLO"), strings.HasPrefix(data, "HELO"): + if len(strings.Split(data, " ")) != 2 { + writeLine("501 Syntax: EHLO hostname") + break + } + if props.FailOnHelo { + writeLine("500 5.5.2 Error: fail on HELO") + break + } + writeLine("250-localhost.localdomain\r\n" + props.FeatureSet) case strings.HasPrefix(data, "MAIL FROM:"): + if props.FailOnMailFrom { + writeLine("500 5.5.2 Error: fail on MAIL FROM") + break + } from := strings.TrimPrefix(data, "MAIL FROM:") from = strings.ReplaceAll(from, "BODY=8BITMIME", "") from = strings.ReplaceAll(from, "SMTPUTF8", "") + if props.SupportDSN { + from = strings.ReplaceAll(from, "RET=FULL", "") + } from = strings.TrimSpace(from) if !strings.EqualFold(from, "") { - _ = writeLine(fmt.Sprintf("503 5.1.2 Invalid from: %s", from)) + writeLine(fmt.Sprintf("503 5.1.2 Invalid from: %s", from)) break } writeOK() case strings.HasPrefix(data, "RCPT TO:"): to := strings.TrimPrefix(data, "RCPT TO:") + if props.SupportDSN { + to = strings.ReplaceAll(to, "NOTIFY=FAILURE,SUCCESS", "") + } to = strings.TrimSpace(to) if !strings.EqualFold(to, "") { - _ = writeLine(fmt.Sprintf("500 5.1.2 Invalid to: %s", to)) + writeLine(fmt.Sprintf("500 5.1.2 Invalid to: %s", to)) break } writeOK() - case strings.HasPrefix(data, "AUTH XOAUTH2"): - auth := strings.TrimPrefix(data, "AUTH XOAUTH2 ") - if !strings.EqualFold(auth, "dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=") { - _ = writeLine("535 5.7.8 Error: authentication failed") + case strings.HasPrefix(data, "AUTH"): + if props.FailOnAuth { + writeLine("535 5.7.8 Error: authentication failed") break } - _ = writeLine("235 2.7.0 Authentication successful") - case strings.HasPrefix(data, "AUTH PLAIN"): - auth := strings.TrimPrefix(data, "AUTH PLAIN ") - if !strings.EqualFold(auth, "AHRvbmlAdGVzdGVyLmNvbQBWM3J5UzNjcjN0Kw==") { - _ = writeLine("535 5.7.8 Error: authentication failed") - break - } - _ = writeLine("235 2.7.0 Authentication successful") - case strings.HasPrefix(data, "AUTH LOGIN"): - var username, password string - userResp := "VXNlcm5hbWU6" - passResp := "UGFzc3dvcmQ6" - if strings.Contains(featureSet, "250-X-MOX-LOGIN") { - userResp = "" - passResp = "UGFzc3dvcmQ=" - } - if strings.Contains(featureSet, "250-X-NULLBYTE-LOGIN") { - userResp = "VXNlciBuYW1lAA==" - passResp = "UGFzc3dvcmQA" - } - if strings.Contains(featureSet, "250-X-BOGUS-LOGIN") { - userResp = "Qm9ndXM=" - passResp = "Qm9ndXM=" - } - if strings.Contains(featureSet, "250-X-EMPTY-LOGIN") { - userResp = "" - passResp = "" - } - _ = writeLine("334 " + userResp) - - ddata, derr := reader.ReadString('\n') - if derr != nil { - fmt.Printf("failed to read username data from connection: %s\n", derr) - break - } - ddata = strings.TrimSpace(ddata) - username = ddata - _ = writeLine("334 " + passResp) - - ddata, derr = reader.ReadString('\n') - if derr != nil { - fmt.Printf("failed to read password data from connection: %s\n", derr) - break - } - ddata = strings.TrimSpace(ddata) - password = ddata - - if !strings.EqualFold(username, "dG9uaUB0ZXN0ZXIuY29t") || - !strings.EqualFold(password, "VjNyeVMzY3IzdCs=") { - _ = writeLine("535 5.7.8 Error: authentication failed") - break - } - _ = writeLine("235 2.7.0 Authentication successful") + writeLine("235 2.7.0 Authentication successful") case strings.EqualFold(data, "DATA"): - _ = writeLine("354 End data with .") + if props.FailOnDataInit { + writeLine("503 5.5.1 Error: fail on DATA init") + break + } + writeLine("354 End data with .") for { ddata, derr := reader.ReadString('\n') if derr != nil { - fmt.Printf("failed to read DATA data from connection: %s\n", derr) + t.Logf("failed to read data from connection: %s", derr) break } ddata = strings.TrimSpace(ddata) - if strings.EqualFold(ddata, "DATA write should fail") { - _ = writeLine("500 5.0.0 Error during DATA transmission") - break - } if ddata == "." { - if strings.Contains(datastring, "DATA close should fail") { - _ = writeLine("500 5.0.0 Error during DATA closing") + if props.FailOnDataClose { + writeLine("500 5.0.0 Error during DATA transmission") break } - _ = writeLine("250 2.0.0 Ok: queued as 1234567890") + if props.FailTemp { + writeLine("451 4.3.0 Error: fail on DATA close") + break + } + writeLine("250 2.0.0 Ok: queued as 1234567890") break } datastring += ddata + "\n" } - case strings.EqualFold(data, "noop"), - strings.EqualFold(data, "vrfy"): + case strings.EqualFold(data, "noop"): + if props.FailOnNoop { + writeLine("500 5.0.0 Error: fail on NOOP") + break + } + writeOK() + case strings.EqualFold(data, "vrfy"): writeOK() case strings.EqualFold(data, "rset"): - if failReset { - _ = writeLine("500 5.1.2 Error: reset failed") + if props.FailOnReset { + writeLine("500 5.1.2 Error: reset failed") break } writeOK() case strings.EqualFold(data, "quit"): - _ = writeLine("221 2.0.0 Bye") + if props.FailOnQuit { + writeLine("500 5.1.2 Error: quit failed") + break + } + writeLine("221 2.0.0 Bye") + return + case strings.EqualFold(data, "starttls"): + if props.FailOnSTARTTLS { + writeLine("500 5.1.2 Error: starttls failed") + break + } + keypair, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + writeLine("500 5.1.2 Error: starttls failed - " + err.Error()) + break + } + writeLine("220 Ready to start TLS") + tlsConfig := &tls.Config{Certificates: []tls.Certificate{keypair}} + connection = tls.Server(connection, tlsConfig) + props.IsTLS = true + handleTestServerConnection(connection, t, props) default: - _ = writeLine("500 5.5.2 Error: bad syntax") + writeLine("500 5.5.2 Error: bad syntax") } } } diff --git a/codecov.yml b/codecov.yml index a7460c1..a9f998e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -6,17 +6,17 @@ coverage: status: project: default: - target: 85% - threshold: 5% + target: 90% + threshold: 2% base: auto if_ci_failed: error only_pulls: false patch: default: - target: 80% + target: 90% base: auto if_ci_failed: error - threshold: 5% + threshold: 2% comment: require_changes: true diff --git a/eml.go b/eml.go index 17077a3..0363d89 100644 --- a/eml.go +++ b/eml.go @@ -60,7 +60,7 @@ func EMLToMsgFromReader(reader io.Reader) (*Msg, error) { return msg, fmt.Errorf("failed to parse EML from reader: %w", err) } - if err := parseEML(parsedMsg, bodybuf, msg); err != nil { + if err = parseEML(parsedMsg, bodybuf, msg); err != nil { return msg, fmt.Errorf("failed to parse EML contents: %w", err) } @@ -93,7 +93,7 @@ func EMLToMsgFromFile(filePath string) (*Msg, error) { return msg, fmt.Errorf("failed to parse EML file: %w", err) } - if err := parseEML(parsedMsg, bodybuf, msg); err != nil { + if err = parseEML(parsedMsg, bodybuf, msg); err != nil { return msg, fmt.Errorf("failed to parse EML contents: %w", err) } @@ -218,9 +218,9 @@ func parseEMLHeaders(mailHeader *netmail.Header, msg *Msg) error { for _, addr := range parsedAddrs { addrStrings = append(addrStrings, addr.String()) } - if err = addrFunc(addrStrings...); err != nil { - return fmt.Errorf(`failed to parse %q header: %w`, addrHeader, err) - } + // We can skip the error checking here since netmail.ParseAddressList already performed the + // same address checking that the msg methods do. + _ = addrFunc(addrStrings...) } } @@ -600,6 +600,8 @@ func parseEMLAttachmentEmbed(contentDisposition []string, multiPart *multipart.P if err := msg.EmbedReader(filename, dataReader); err != nil { return fmt.Errorf("failed to embed multipart body: %w", err) } + default: + return errors.New("unsupported content disposition type") } return nil } diff --git a/eml_test.go b/eml_test.go index 44bfb54..f89801a 100644 --- a/eml_test.go +++ b/eml_test.go @@ -6,11 +6,8 @@ package mail import ( "bytes" - "fmt" - "os" "strings" "testing" - "time" ) const ( @@ -22,6 +19,23 @@ Subject: Saying Hello Date: Fri, 21 Nov 1997 09:55:06 -0600 Message-ID: <1234@local.machine.example> +This is a message just to say hello. +So, "Hello".` + exampleMailRFC5322A11InvalidFrom = `From: §§§§§§§§§ +To: Mary Smith +Subject: Saying Hello +Date: Fri, 21 Nov 1997 09:55:06 -0600 +Message-ID: <1234@local.machine.example> + +This is a message just to say hello. +So, "Hello".` + exampleMailInvalidHeader = `From: John Doe +To: Mary Smith +Inva@id*Header; This is a header +Subject: Saying Hello +Date: Fri, 21 Nov 1997 09:55:06 -0600 +Message-ID: <1234@local.machine.example> + This is a message just to say hello. So, "Hello".` exampleMailPlainNoEnc = `Date: Wed, 01 Nov 2023 00:00:00 +0000 @@ -42,6 +56,52 @@ This is a test mail. Please do not reply to this. Also this line is very long so should be wrapped. +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailPlainInvalidCTE = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: invalid + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + +Thank your for your business! +The go-mail team + +-- +This is a signature` + exampleMailInvalidContentType = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text without encoding +User-Agent: go-mail v0.4.0 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.0 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: text/plain @ charset=UTF-8; $foo; bar; --invalid-- +Content-Transfer-Encoding: 8bit + +Dear Customer, + +This is a test mail. Please do not reply to this. Also this line is very long so it +should be wrapped. + + Thank your for your business! The go-mail team @@ -304,6 +364,128 @@ VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64WithAttachmentNoContentType = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding: base64 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: base64 + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64WithAttachmentBrokenB64 = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGl§§§§§@@@@@XMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: base64 +Content-Type: application/octet-stream; name="test.attachment" + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAg§§§§§@@@@@BuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64WithAttachmentInvalidCTE = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding: invalid +Content-Type: text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: invalid +Content-Type: application/octet-stream; name="test.attachment" + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` + exampleMailPlainB64WithAttachmentInvalidContentType = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // plain text base64 with attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary=45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Transfer-Encoding: base64 +Content-Type: text/plain; charset=UTF-8 + +RGVhciBDdXN0b21lciwKClRoaXMgaXMgYSB0ZXN0IG1haWwuIFBsZWFzZSBkbyBub3QgcmVwbHkg +dG8gdGhpcy4gQWxzbyB0aGlzIGxpbmUgaXMgdmVyeSBsb25nIHNvIGl0CnNob3VsZCBiZSB3cmFw +cGVkLgoKClRoYW5rIHlvdXIgZm9yIHlvdXIgYnVzaW5lc3MhClRoZSBnby1tYWlsIHRlYW0KCi0t +ClRoaXMgaXMgYSBzaWduYXR1cmU= + +--45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7 +Content-Disposition: attachment; filename="test.attachment" +Content-Transfer-Encoding: base64 +Content-Type; text/plain @ charset=UTF-8; $foo; bar; --invalid-- + +VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg +ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh +Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== + --45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--` exampleMailPlainB64WithAttachmentNoBoundary = `Date: Wed, 01 Nov 2023 00:00:00 +0000 MIME-Version: 1.0 @@ -578,6 +760,39 @@ Content-Disposition: attachment; filename="testfile.txt" VGhpcyBpcyBhIHRlc3QgaW4gQmFzZTY0 +--------------26A45336F6C6196BD8BBA2A2--` + exampleMultiPart7BitBase64BrokenB64 = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail // 7bit with base64 attachment +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Cc: +Content-Type: multipart/mixed; + boundary="------------26A45336F6C6196BD8BBA2A2" + +This is a multi-part message in MIME format. +--------------26A45336F6C6196BD8BBA2A2 +Content-Type: text/plain; charset=US-ASCII; format=flowed +Content-Transfer-Encoding: 7bit + +testtest +testtest +testtest +testtest +testtest +testtest + +--------------26A45336F6C6196BD8BBA2A2 +Content-Type: text/plain; charset=UTF-8; + name="testfile.txt" +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; + filename="testfile.txt" + +VGh@@@@§§§§hIHRlc3QgaW4gQmFzZTY0 --------------26A45336F6C6196BD8BBA2A2--` exampleMultiPart8BitBase64 = `Date: Wed, 01 Nov 2023 00:00:00 +0000 MIME-Version: 1.0 @@ -612,8 +827,352 @@ Content-Disposition: attachment; VGhpcyBpcyBhIHRlc3QgaW4gQmFzZTY0 --------------26A45336F6C6196BD8BBA2A2--` + exampleMailWithInlineEmbed = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail with inline embed +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Content-Type: multipart/related; boundary="abc123" + +--abc123 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + + + +

Hello,

+

This is an example email with an inline image:

+ Inline Image +

Best regards,
The go-mail team

+ + +--abc123 +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-ID: <12345@go-mail.dev> +Content-Disposition: inline; filename="test.png" + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2O +UAAAAABJRU5ErkJggg== +--abc123--` + exampleMailWithInlineEmbedWrongDisposition = `Date: Wed, 01 Nov 2023 00:00:00 +0000 +MIME-Version: 1.0 +Message-ID: <1305604950.683004066175.AAAAAAAAaaaaaaaaB@go-mail.dev> +Subject: Example mail with inline embed +User-Agent: go-mail v0.4.1 // https://github.com/wneessen/go-mail +X-Mailer: go-mail v0.4.1 // https://github.com/wneessen/go-mail +From: "Toni Tester" +To: +Content-Type: multipart/related; boundary="abc123" + +--abc123 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + + + +

Hello,

+

This is an example email with an inline image:

+ Inline Image +

Best regards,
The go-mail team

+ + +--abc123 +Content-Type: image/png +Content-Transfer-Encoding: base64 +Content-ID: <12345@go-mail.dev> +Content-Disposition: broken; filename="test.png" + +iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2O +UAAAAABJRU5ErkJggg== +--abc123--` ) +func TestEMLToMsgFromReader(t *testing.T) { + t.Run("EMLToMsgFromReader via EMLToMsgFromString, check subject and encoding", func(t *testing.T) { + tests := []struct { + name string + emlString string + wantEncoding Encoding + wantSubject string + }{ + { + "RFC5322 A1.1 example mail", exampleMailRFC5322A11, EncodingUSASCII, + "Saying Hello", + }, + { + "Plain text no encoding (7bit)", exampleMailPlain7Bit, EncodingUSASCII, + "Example mail // plain text without encoding", + }, + { + "Plain text no encoding", exampleMailPlainNoEnc, NoEncoding, + "Example mail // plain text without encoding", + }, + { + "Plain text quoted-printable", exampleMailPlainQP, EncodingQP, + "Example mail // plain text quoted-printable", + }, + { + "Plain text base64", exampleMailPlainB64, EncodingB64, + "Example mail // plain text base64", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parsed, err := EMLToMsgFromString(tt.emlString) + if err != nil { + t.Fatalf("failed to parse EML string: %s", err) + } + if parsed.Encoding() != tt.wantEncoding.String() { + t.Errorf("failed to parse EML string: want encoding %s, got %s", tt.wantEncoding, + parsed.Encoding()) + } + gotSubject, ok := parsed.genHeader[HeaderSubject] + if !ok { + t.Fatalf("failed to parse EML string. No subject header found") + } + if len(gotSubject) != 1 { + t.Fatalf("failed to parse EML string, more than one subject header found") + } + if !strings.EqualFold(gotSubject[0], tt.wantSubject) { + t.Errorf("failed to parse EML string: want subject %s, got %s", tt.wantSubject, + gotSubject[0]) + } + }) + } + }) + t.Run("EMLToMsgFromReader fails on reader", func(t *testing.T) { + emlReader := bytes.NewBufferString("invalid") + if _, err := EMLToMsgFromReader(emlReader); err == nil { + t.Errorf("EML parsing with invalid EML string should fail") + } + }) + t.Run("EMLToMsgFromReader fails on parseEML", func(t *testing.T) { + emlReader := bytes.NewBufferString(exampleMailRFC5322A11InvalidFrom) + if _, err := EMLToMsgFromReader(emlReader); err == nil { + t.Errorf("EML parsing with invalid EML string should fail") + } + }) + t.Run("EMLToMsgFromReader via EMLToMsgFromString on different examples", func(t *testing.T) { + tests := []struct { + name string + emlString string + shouldFail bool + }{ + { + name: "Valid RFC 5322 Example", + emlString: exampleMailRFC5322A11, + shouldFail: false, + }, + { + name: "Invalid From Header (RFC 5322)", + emlString: exampleMailRFC5322A11InvalidFrom, + shouldFail: true, + }, + { + name: "Invalid Header", + emlString: exampleMailInvalidHeader, + shouldFail: true, + }, + { + name: "Plain broken Content-Type", + emlString: exampleMailInvalidContentType, + shouldFail: true, + }, + { + name: "Plain No Encoding", + emlString: exampleMailPlainNoEnc, + shouldFail: false, + }, + { + name: "Plain invalid CTE", + emlString: exampleMailPlainInvalidCTE, + shouldFail: true, + }, + { + name: "Plain 7bit", + emlString: exampleMailPlain7Bit, + shouldFail: false, + }, + { + name: "Broken Body Base64", + emlString: exampleMailPlainBrokenBody, + shouldFail: true, + }, + { + name: "Unknown Content Type", + emlString: exampleMailPlainUnknownContentType, + shouldFail: true, + }, + { + name: "Broken Header", + emlString: exampleMailPlainBrokenHeader, + shouldFail: true, + }, + { + name: "Broken From Header", + emlString: exampleMailPlainBrokenFrom, + shouldFail: true, + }, + { + name: "Broken To Header", + emlString: exampleMailPlainBrokenTo, + shouldFail: true, + }, + { + name: "Invalid Date", + emlString: exampleMailPlainNoEncInvalidDate, + shouldFail: true, + }, + { + name: "No Date Header", + emlString: exampleMailPlainNoEncNoDate, + shouldFail: false, + }, + { + name: "Quoted Printable Encoding", + emlString: exampleMailPlainQP, + shouldFail: false, + }, + { + name: "Unsupported Transfer Encoding", + emlString: exampleMailPlainUnsupportedTransferEnc, + shouldFail: true, + }, + { + name: "Base64 Encoding", + emlString: exampleMailPlainB64, + shouldFail: false, + }, + { + name: "Base64 with Attachment", + emlString: exampleMailPlainB64WithAttachment, + shouldFail: false, + }, + { + name: "Base64 with Attachment no content types", + emlString: exampleMailPlainB64WithAttachmentNoContentType, + shouldFail: true, + }, + { + name: "Multipart Base64 with Attachment broken Base64", + emlString: exampleMailPlainB64WithAttachmentBrokenB64, + shouldFail: true, + }, + { + name: "Base64 with Attachment with invalid content type in attachment", + emlString: exampleMailPlainB64WithAttachmentInvalidContentType, + shouldFail: true, + }, + { + name: "Base64 with Attachment with invalid CTE in attachment", + emlString: exampleMailPlainB64WithAttachmentInvalidCTE, + shouldFail: true, + }, + { + name: "Base64 with Attachment No Boundary", + emlString: exampleMailPlainB64WithAttachmentNoBoundary, + shouldFail: true, + }, + { + name: "Broken Body Base64", + emlString: exampleMailPlainB64BrokenBody, + shouldFail: true, + }, + { + name: "Base64 with Embedded Image", + emlString: exampleMailPlainB64WithEmbed, + shouldFail: false, + }, + { + name: "Base64 with Embed No Content-ID", + emlString: exampleMailPlainB64WithEmbedNoContentID, + shouldFail: false, + }, + { + name: "Multipart Mixed with Attachment, Embed, and Alternative Part", + emlString: exampleMailMultipartMixedAlternativeRelated, + shouldFail: false, + }, + { + name: "Multipart 7bit Base64", + emlString: exampleMultiPart7BitBase64, + shouldFail: false, + }, + { + name: "Multipart 7bit Base64 with broken Base64", + emlString: exampleMultiPart7BitBase64BrokenB64, + shouldFail: true, + }, + { + name: "Multipart 8bit Base64", + emlString: exampleMultiPart8BitBase64, + shouldFail: false, + }, + { + name: "Multipart with inline embed", + emlString: exampleMailWithInlineEmbed, + shouldFail: false, + }, + { + name: "Multipart with inline embed disposition broken", + emlString: exampleMailWithInlineEmbedWrongDisposition, + shouldFail: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := EMLToMsgFromString(tt.emlString) + if tt.shouldFail && err == nil { + t.Errorf("parsing of EML was supposed to fail, but it did not") + } + if !tt.shouldFail && err != nil { + t.Errorf("parsing of EML failed: %s", err) + } + }) + } + }) +} + +func TestEMLToMsgFromFile(t *testing.T) { + t.Run("EMLToMsgFromFile succeeds", func(t *testing.T) { + parsed, err := EMLToMsgFromFile("testdata/RFC5322-A1-1.eml") + if err != nil { + t.Fatalf("EMLToMsgFromFile failed: %s ", err) + } + if parsed.Encoding() != EncodingUSASCII.String() { + t.Errorf("EMLToMsgFromFile failed: want encoding %s, got %s", EncodingUSASCII, + parsed.Encoding()) + } + gotSubject, ok := parsed.genHeader[HeaderSubject] + if !ok { + t.Fatalf("failed to parse EML string. No subject header found") + } + if len(gotSubject) != 1 { + t.Fatalf("failed to parse EML string, more than one subject header found") + } + if !strings.EqualFold(gotSubject[0], "Saying Hello") { + t.Errorf("failed to parse EML string: want subject %s, got %s", "Saying Hello", + gotSubject[0]) + } + }) + t.Run("EMLToMsgFromFile fails on file not found", func(t *testing.T) { + if _, err := EMLToMsgFromFile("testdata/not-existing.eml"); err == nil { + t.Errorf("EMLToMsgFromFile with invalid file should fail") + } + }) + t.Run("EMLToMsgFromFile fails on parseEML", func(t *testing.T) { + if _, err := EMLToMsgFromFile("testdata/RFC5322-A1-1-invalid-from.eml"); err == nil { + t.Errorf("EMLToMsgFromFile with invalid EML message should fail") + } + }) +} + +/* func TestEMLToMsgFromString(t *testing.T) { tests := []struct { name string @@ -621,26 +1180,6 @@ func TestEMLToMsgFromString(t *testing.T) { enc string sub string }{ - { - "RFC5322 A1.1", exampleMailRFC5322A11, "7bit", - "Saying Hello", - }, - { - "Plain text no encoding (7bit)", exampleMailPlain7Bit, "7bit", - "Example mail // plain text without encoding", - }, - { - "Plain text no encoding", exampleMailPlainNoEnc, "8bit", - "Example mail // plain text without encoding", - }, - { - "Plain text quoted-printable", exampleMailPlainQP, "quoted-printable", - "Example mail // plain text quoted-printable", - }, - { - "Plain text base64", exampleMailPlainB64, "base64", - "Example mail // plain text base64", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1009,3 +1548,6 @@ func stringToTempFile(data, name string) (string, string, error) { } return tempDir, filePath, nil } + + +*/ diff --git a/file_test.go b/file_test.go index 43b8cfe..66ec84f 100644 --- a/file_test.go +++ b/file_test.go @@ -6,134 +6,183 @@ package mail import "testing" -// TestFile_SetGetHeader tests the set-/getHeader method of the File object -func TestFile_SetGetHeader(t *testing.T) { - f := File{ - Name: "testfile.txt", - Header: make(map[string][]string), - } - f.setHeader(HeaderContentType, "text/plain") - fi, ok := f.getHeader(HeaderContentType) - if !ok { - t.Errorf("getHeader method of File did not return a value") - return - } - if fi != "text/plain" { - t.Errorf("getHeader returned wrong value. Expected: %s, got: %s", "text/plain", fi) - } - fi, ok = f.getHeader(HeaderContentTransferEnc) - if ok { - t.Errorf("getHeader method of File did return a value, but wasn't supposed to") - return - } - if fi != "" { - t.Errorf("getHeader returned wrong value. Expected: %s, got: %s", "", fi) - } -} - -// TestFile_WithFileDescription tests the WithFileDescription option -func TestFile_WithFileDescription(t *testing.T) { - tests := []struct { - name string - desc string - }{ - {"File description: test", "test"}, - {"File description: empty", ""}, - } - for _, tt := range tests { - m := NewMsg() - t.Run(tt.name, func(t *testing.T) { - m.AttachFile("file.go", WithFileDescription(tt.desc)) - al := m.GetAttachments() - if len(al) <= 0 { - t.Errorf("AttachFile() failed. Attachment list is empty") - } - a := al[0] - if a.Desc != tt.desc { - t.Errorf("WithFileDescription() failed. Expected: %s, got: %s", tt.desc, a.Desc) - } - }) - } -} - -// TestFile_WithContentID tests the WithFileContentID option -func TestFile_WithContentID(t *testing.T) { - tests := []struct { - name string - contentid string - }{ - {"File Content-ID: test", "test"}, - {"File Content-ID: empty", ""}, - } - for _, tt := range tests { - m := NewMsg() - t.Run(tt.name, func(t *testing.T) { - m.AttachFile("file.go", WithFileContentID(tt.contentid)) - al := m.GetAttachments() - if len(al) <= 0 { - t.Errorf("AttachFile() failed. Attachment list is empty") - } - a := al[0] - if a.Header.Get(HeaderContentID.String()) != tt.contentid { - t.Errorf("WithFileContentID() failed. Expected: %s, got: %s", tt.contentid, - a.Header.Get(HeaderContentID.String())) - } - }) - } -} - -// TestFile_WithFileEncoding tests the WithFileEncoding option -func TestFile_WithFileEncoding(t *testing.T) { - tests := []struct { - name string - enc Encoding - want Encoding - }{ - {"File encoding: 8bit raw", NoEncoding, NoEncoding}, - {"File encoding: Base64", EncodingB64, EncodingB64}, - {"File encoding: quoted-printable (not allowed)", EncodingQP, ""}, - } - for _, tt := range tests { - m := NewMsg() - t.Run(tt.name, func(t *testing.T) { - m.AttachFile("file.go", WithFileEncoding(tt.enc)) - al := m.GetAttachments() - if len(al) <= 0 { - t.Errorf("AttachFile() failed. Attachment list is empty") - } - a := al[0] - if a.Enc != tt.want { - t.Errorf("WithFileEncoding() failed. Expected: %s, got: %s", tt.enc, a.Enc) - } - }) - } -} - -// TestFile_WithFileContentType tests the WithFileContentType option -func TestFile_WithFileContentType(t *testing.T) { - tests := []struct { - name string - ct ContentType - want string - }{ - {"File content-type: text/plain", TypeTextPlain, "text/plain"}, - {"File content-type: html/html", TypeTextHTML, "text/html"}, - {"File content-type: application/octet-stream", TypeAppOctetStream, "application/octet-stream"}, - {"File content-type: application/pgp-encrypted", TypePGPEncrypted, "application/pgp-encrypted"}, - {"File content-type: application/pgp-signature", TypePGPSignature, "application/pgp-signature"}, - } - for _, tt := range tests { - m := NewMsg() - t.Run(tt.name, func(t *testing.T) { - m.AttachFile("file.go", WithFileContentType(tt.ct)) - al := m.GetAttachments() - if len(al) <= 0 { - t.Errorf("AttachFile() failed. Attachment list is empty") - } - a := al[0] - if a.ContentType != ContentType(tt.want) { - t.Errorf("WithFileContentType() failed. Expected: %s, got: %s", tt.want, a.ContentType) - } - }) - } +func TestFile(t *testing.T) { + t.Run("setHeader", func(t *testing.T) { + f := File{ + Name: "testfile.txt", + Header: make(map[string][]string), + } + f.setHeader(HeaderContentType, "text/plain") + contentType, ok := f.Header[HeaderContentType.String()] + if !ok { + t.Fatalf("setHeader failed. Expected header %s to be set", HeaderContentType) + } + if len(contentType) != 1 { + t.Fatalf("setHeader failed. Expected header %s to have one value, got: %d", HeaderContentType, + len(contentType)) + } + if contentType[0] != "text/plain" { + t.Fatalf("setHeader failed. Expected header %s to have value %s, got: %s", + HeaderContentType.String(), "text/plain", contentType[0]) + } + }) + t.Run("getHeader", func(t *testing.T) { + f := File{ + Name: "testfile.txt", + Header: make(map[string][]string), + } + f.setHeader(HeaderContentType, "text/plain") + contentType, ok := f.getHeader(HeaderContentType) + if !ok { + t.Fatalf("setHeader failed. Expected header %s to be set", HeaderContentType) + } + if contentType != "text/plain" { + t.Fatalf("setHeader failed. Expected header %s to have value %s, got: %s", + HeaderContentType.String(), "text/plain", contentType) + } + }) + t.Run("WithFileDescription", func(t *testing.T) { + tests := []struct { + name string + desc string + }{ + {"File description: test", "test"}, + {"File description: with newline", "test\n"}, + {"File description: empty", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + message.AttachFile("file.go", WithFileDescription(tt.desc)) + attachments := message.GetAttachments() + if len(attachments) <= 0 { + t.Fatalf("failed to retrieve attachments list") + } + firstAttachment := attachments[0] + if firstAttachment == nil { + t.Fatalf("failed to retrieve first attachment, got nil") + } + if firstAttachment.Desc != tt.desc { + t.Errorf("WithFileDescription() failed. Expected: %s, got: %s", tt.desc, + firstAttachment.Desc) + } + }) + } + }) + t.Run("WithFileContentID", func(t *testing.T) { + tests := []struct { + name string + id string + }{ + {"Content-ID: test", "test"}, + {"Content-ID: with newline", "test\n"}, + {"Content-ID: empty", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + message.AttachFile("file.go", WithFileContentID(tt.id)) + attachments := message.GetAttachments() + if len(attachments) <= 0 { + t.Fatalf("failed to retrieve attachments list") + } + firstAttachment := attachments[0] + if firstAttachment == nil { + t.Fatalf("failed to retrieve first attachment, got nil") + } + contentID := firstAttachment.Header.Get(HeaderContentID.String()) + if contentID != tt.id { + t.Errorf("WithFileContentID() failed. Expected: %s, got: %s", tt.id, + contentID) + } + }) + } + }) + t.Run("WithFileEncoding", func(t *testing.T) { + tests := []struct { + name string + encoding Encoding + want Encoding + }{ + {"File encoding: US-ASCII", EncodingUSASCII, EncodingUSASCII}, + {"File encoding: 8bit raw", NoEncoding, NoEncoding}, + {"File encoding: Base64", EncodingB64, EncodingB64}, + {"File encoding: quoted-printable (not allowed)", EncodingQP, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + message.AttachFile("file.go", WithFileEncoding(tt.encoding)) + attachments := message.GetAttachments() + if len(attachments) <= 0 { + t.Fatalf("failed to retrieve attachments list") + } + firstAttachment := attachments[0] + if firstAttachment == nil { + t.Fatalf("failed to retrieve first attachment, got nil") + } + if firstAttachment.Enc != tt.want { + t.Errorf("WithFileEncoding() failed. Expected: %s, got: %s", tt.want, firstAttachment.Enc) + } + }) + } + }) + t.Run("WithFileName", func(t *testing.T) { + tests := []struct { + name string + fileName string + }{ + {"File name: test", "test"}, + {"File name: with newline", "test\n"}, + {"File name: empty", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + message.AttachFile("file.go", WithFileName(tt.fileName)) + attachments := message.GetAttachments() + if len(attachments) <= 0 { + t.Fatalf("failed to retrieve attachments list") + } + firstAttachment := attachments[0] + if firstAttachment == nil { + t.Fatalf("failed to retrieve first attachment, got nil") + } + if firstAttachment.Name != tt.fileName { + t.Errorf("WithFileName() failed. Expected: %s, got: %s", tt.fileName, + firstAttachment.Name) + } + }) + } + }) + t.Run("WithFileContentType", func(t *testing.T) { + tests := []struct { + name string + contentType ContentType + }{ + {"File content-type: text/plain", TypeTextPlain}, + {"File content-type: html/html", TypeTextHTML}, + {"File content-type: application/octet-stream", TypeAppOctetStream}, + {"File content-type: application/pgp-encrypted", TypePGPEncrypted}, + {"File content-type: application/pgp-signature", TypePGPSignature}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + message.AttachFile("file.go", WithFileContentType(tt.contentType)) + attachments := message.GetAttachments() + if len(attachments) <= 0 { + t.Fatalf("failed to retrieve attachments list") + } + firstAttachment := attachments[0] + if firstAttachment == nil { + t.Fatalf("failed to retrieve first attachment, got nil") + } + if firstAttachment.ContentType != tt.contentType { + t.Errorf("WithFileContentType() failed. Expected: %s, got: %s", tt.contentType, + firstAttachment.ContentType) + } + }) + } + }) } diff --git a/header_test.go b/header_test.go index a060ae6..a554936 100644 --- a/header_test.go +++ b/header_test.go @@ -8,69 +8,13 @@ import ( "testing" ) -// TestImportance_StringFuncs tests the different string method of the Importance object -func TestImportance_StringFuncs(t *testing.T) { - tests := []struct { +var ( + genHeaderTests = []struct { name string - imp Importance - wantns string - xprio string + header Header want string }{ - {"Importance: Non-Urgent", ImportanceNonUrgent, "0", "5", "non-urgent"}, - {"Importance: Low", ImportanceLow, "0", "5", "low"}, - {"Importance: Normal", ImportanceNormal, "", "", ""}, - {"Importance: High", ImportanceHigh, "1", "1", "high"}, - {"Importance: Urgent", ImportanceUrgent, "1", "1", "urgent"}, - {"Importance: Unknown", 9, "", "", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.imp.NumString() != tt.wantns { - t.Errorf("wrong number string for Importance returned. Expected: %s, got: %s", - tt.wantns, tt.imp.NumString()) - } - if tt.imp.XPrioString() != tt.xprio { - t.Errorf("wrong x-prio string for Importance returned. Expected: %s, got: %s", - tt.xprio, tt.imp.XPrioString()) - } - if tt.imp.String() != tt.want { - t.Errorf("wrong string for Importance returned. Expected: %s, got: %s", - tt.want, tt.imp.String()) - } - }) - } -} - -// TestAddrHeader_String tests the string method of the AddrHeader object -func TestAddrHeader_String(t *testing.T) { - tests := []struct { - name string - ah AddrHeader - want string - }{ - {"Address header: From", HeaderFrom, "From"}, - {"Address header: To", HeaderTo, "To"}, - {"Address header: Cc", HeaderCc, "Cc"}, - {"Address header: Bcc", HeaderBcc, "Bcc"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.ah.String() != tt.want { - t.Errorf("wrong string for AddrHeader returned. Expected: %s, got: %s", - tt.want, tt.ah.String()) - } - }) - } -} - -// TestHeader_String tests the string method of the Header object -func TestHeader_String(t *testing.T) { - tests := []struct { - name string - h Header - want string - }{ + {"Header: Content-Description", HeaderContentDescription, "Content-Description"}, {"Header: Content-Disposition", HeaderContentDisposition, "Content-Disposition"}, {"Header: Content-ID", HeaderContentID, "Content-ID"}, {"Header: Content-Language", HeaderContentLang, "Content-Language"}, @@ -78,6 +22,10 @@ func TestHeader_String(t *testing.T) { {"Header: Content-Transfer-Encoding", HeaderContentTransferEnc, "Content-Transfer-Encoding"}, {"Header: Content-Type", HeaderContentType, "Content-Type"}, {"Header: Date", HeaderDate, "Date"}, + { + "Header: Disposition-Notification-To", HeaderDispositionNotificationTo, + "Disposition-Notification-To", + }, {"Header: Importance", HeaderImportance, "Importance"}, {"Header: In-Reply-To", HeaderInReplyTo, "In-Reply-To"}, {"Header: List-Unsubscribe", HeaderListUnsubscribe, "List-Unsubscribe"}, @@ -87,19 +35,90 @@ func TestHeader_String(t *testing.T) { {"Header: Organization", HeaderOrganization, "Organization"}, {"Header: Precedence", HeaderPrecedence, "Precedence"}, {"Header: Priority", HeaderPriority, "Priority"}, - {"Header: HeaderReferences", HeaderReferences, "References"}, + {"Header: References", HeaderReferences, "References"}, {"Header: Reply-To", HeaderReplyTo, "Reply-To"}, {"Header: Subject", HeaderSubject, "Subject"}, {"Header: User-Agent", HeaderUserAgent, "User-Agent"}, + {"Header: X-Auto-Response-Suppress", HeaderXAutoResponseSuppress, "X-Auto-Response-Suppress"}, {"Header: X-Mailer", HeaderXMailer, "X-Mailer"}, {"Header: X-MSMail-Priority", HeaderXMSMailPriority, "X-MSMail-Priority"}, {"Header: X-Priority", HeaderXPriority, "X-Priority"}, } - for _, tt := range tests { + addrHeaderTests = []struct { + name string + header AddrHeader + want string + }{ + {"From", HeaderFrom, "From"}, + {"To", HeaderTo, "To"}, + {"Cc", HeaderCc, "Cc"}, + {"Bcc", HeaderBcc, "Bcc"}, + } +) + +func TestImportance_Stringer(t *testing.T) { + tests := []struct { + name string + imp Importance + wantnum string + xprio string + want string + }{ + {"Non-Urgent", ImportanceNonUrgent, "0", "5", "non-urgent"}, + {"Low", ImportanceLow, "0", "5", "low"}, + {"Normal", ImportanceNormal, "", "", ""}, + {"High", ImportanceHigh, "1", "1", "high"}, + {"Urgent", ImportanceUrgent, "1", "1", "urgent"}, + {"Unknown", 9, "", "", ""}, + } + t.Run("String", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.imp.String() != tt.want { + t.Errorf("wrong string for Importance returned. Expected: %s, got: %s", tt.want, tt.imp.String()) + } + }) + } + }) + t.Run("NumString", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.imp.NumString() != tt.wantnum { + t.Errorf("wrong number string for Importance returned. Expected: %s, got: %s", tt.wantnum, + tt.imp.NumString()) + } + }) + } + }) + t.Run("XPrioString", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.imp.XPrioString() != tt.xprio { + t.Errorf("wrong x-prio string for Importance returned. Expected: %s, got: %s", tt.xprio, + tt.imp.XPrioString()) + } + }) + } + }) +} + +func TestAddrHeader_Stringer(t *testing.T) { + for _, tt := range addrHeaderTests { t.Run(tt.name, func(t *testing.T) { - if tt.h.String() != tt.want { - t.Errorf("wrong string for Header returned. Expected: %s, got: %s", - tt.want, tt.h.String()) + if tt.header.String() != tt.want { + t.Errorf("wrong string for AddrHeader returned. Expected: %s, got: %s", + tt.want, tt.header.String()) + } + }) + } +} + +func TestHeader_Stringer(t *testing.T) { + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + if tt.header.String() != tt.want { + t.Errorf("wrong string for Header returned. Expected: %s, got: %s", + tt.want, tt.header.String()) } }) } diff --git a/msg.go b/msg.go index 61feda1..0b06a22 100644 --- a/msg.go +++ b/msg.go @@ -583,6 +583,9 @@ func (m *Msg) SetAddrHeader(header AddrHeader, values ...string) error { // References: // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) { + if m.addrHeader == nil { + m.addrHeader = make(map[AddrHeader][]*mail.Address) + } var addresses []*mail.Address for _, addrVal := range values { address, err := mail.ParseAddress(m.encodeString(addrVal)) @@ -591,7 +594,14 @@ func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) { } addresses = append(addresses, address) } - m.addrHeader[header] = addresses + switch header { + case HeaderFrom: + if len(addresses) > 0 { + m.addrHeader[header] = []*mail.Address{addresses[0]} + } + default: + m.addrHeader[header] = addresses + } } // EnvelopeFrom sets the envelope from address for the Msg. @@ -743,7 +753,16 @@ func (m *Msg) ToIgnoreInvalid(rcpts ...string) { // References: // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) ToFromString(rcpts string) error { - return m.To(strings.Split(rcpts, ",")...) + src := strings.Split(rcpts, ",") + var dst []string + for _, address := range src { + address = strings.TrimSpace(address) + if address == "" { + continue + } + dst = append(dst, address) + } + return m.To(dst...) } // Cc sets one or more "CC" (carbon copy) addresses in the mail body for the Msg. @@ -828,7 +847,16 @@ func (m *Msg) CcIgnoreInvalid(rcpts ...string) { // References: // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) CcFromString(rcpts string) error { - return m.Cc(strings.Split(rcpts, ",")...) + src := strings.Split(rcpts, ",") + var dst []string + for _, address := range src { + address = strings.TrimSpace(address) + if address == "" { + continue + } + dst = append(dst, address) + } + return m.Cc(dst...) } // Bcc sets one or more "BCC" (blind carbon copy) addresses in the mail body for the Msg. @@ -914,7 +942,16 @@ func (m *Msg) BccIgnoreInvalid(rcpts ...string) { // References: // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) BccFromString(rcpts string) error { - return m.Bcc(strings.Split(rcpts, ",")...) + src := strings.Split(rcpts, ",") + var dst []string + for _, address := range src { + address = strings.TrimSpace(address) + if address == "" { + continue + } + dst = append(dst, address) + } + return m.Bcc(dst...) } // ReplyTo sets the "Reply-To" address for the Msg, specifying where replies should be sent. @@ -1050,8 +1087,7 @@ func (m *Msg) SetBulk() { // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.3 // - https://datatracker.ietf.org/doc/html/rfc1123 func (m *Msg) SetDate() { - now := time.Now().Format(time.RFC1123Z) - m.SetGenHeader(HeaderDate, now) + m.SetDateWithValue(time.Now()) } // SetDateWithValue sets the "Date" header for the Msg using the provided time value in a valid RFC 1123 format. @@ -1151,6 +1187,9 @@ func (m *Msg) IsDelivered() bool { // References: // - https://datatracker.ietf.org/doc/html/rfc8098 func (m *Msg) RequestMDNTo(rcpts ...string) error { + if m.genHeader == nil { + m.genHeader = make(map[Header][]string) + } var addresses []string for _, addrVal := range rcpts { address, err := mail.ParseAddress(addrVal) @@ -1159,9 +1198,7 @@ func (m *Msg) RequestMDNTo(rcpts ...string) error { } addresses = append(addresses, address.String()) } - if _, ok := m.genHeader[HeaderDispositionNotificationTo]; ok { - m.genHeader[HeaderDispositionNotificationTo] = addresses - } + m.genHeader[HeaderDispositionNotificationTo] = addresses return nil } @@ -1200,11 +1237,11 @@ func (m *Msg) RequestMDNAddTo(rcpt string) error { return fmt.Errorf(errParseMailAddr, rcpt, err) } var addresses []string - addresses = append(addresses, m.genHeader[HeaderDispositionNotificationTo]...) - addresses = append(addresses, address.String()) - if _, ok := m.genHeader[HeaderDispositionNotificationTo]; ok { - m.genHeader[HeaderDispositionNotificationTo] = addresses + if current, ok := m.genHeader[HeaderDispositionNotificationTo]; ok { + addresses = current } + addresses = append(addresses, address.String()) + m.genHeader[HeaderDispositionNotificationTo] = addresses return nil } @@ -1644,11 +1681,11 @@ func (m *Msg) SetBodyHTMLTemplate(tpl *ht.Template, data interface{}, opts ...Pa if tpl == nil { return errors.New(errTplPointerNil) } - buffer := bytes.Buffer{} - if err := tpl.Execute(&buffer, data); err != nil { + buffer := bytes.NewBuffer(nil) + if err := tpl.Execute(buffer, data); err != nil { return fmt.Errorf(errTplExecuteFailed, err) } - writeFunc := writeFuncFromBuffer(&buffer) + writeFunc := writeFuncFromBuffer(buffer) m.SetBodyWriter(TypeTextHTML, writeFunc, opts...) return nil } @@ -1675,11 +1712,11 @@ func (m *Msg) SetBodyTextTemplate(tpl *tt.Template, data interface{}, opts ...Pa if tpl == nil { return errors.New(errTplPointerNil) } - buf := bytes.Buffer{} - if err := tpl.Execute(&buf, data); err != nil { + buffer := bytes.NewBuffer(nil) + if err := tpl.Execute(buffer, data); err != nil { return fmt.Errorf(errTplExecuteFailed, err) } - writeFunc := writeFuncFromBuffer(&buf) + writeFunc := writeFuncFromBuffer(buffer) m.SetBodyWriter(TypeTextPlain, writeFunc, opts...) return nil } @@ -1749,11 +1786,11 @@ func (m *Msg) AddAlternativeHTMLTemplate(tpl *ht.Template, data interface{}, opt if tpl == nil { return errors.New(errTplPointerNil) } - buffer := bytes.Buffer{} - if err := tpl.Execute(&buffer, data); err != nil { + buffer := bytes.NewBuffer(nil) + if err := tpl.Execute(buffer, data); err != nil { return fmt.Errorf(errTplExecuteFailed, err) } - writeFunc := writeFuncFromBuffer(&buffer) + writeFunc := writeFuncFromBuffer(buffer) m.AddAlternativeWriter(TypeTextHTML, writeFunc, opts...) return nil } @@ -1779,11 +1816,11 @@ func (m *Msg) AddAlternativeTextTemplate(tpl *tt.Template, data interface{}, opt if tpl == nil { return errors.New(errTplPointerNil) } - buffer := bytes.Buffer{} - if err := tpl.Execute(&buffer, data); err != nil { + buffer := bytes.NewBuffer(nil) + if err := tpl.Execute(buffer, data); err != nil { return fmt.Errorf(errTplExecuteFailed, err) } - writeFunc := writeFuncFromBuffer(&buffer) + writeFunc := writeFuncFromBuffer(buffer) m.AddAlternativeWriter(TypeTextPlain, writeFunc, opts...) return nil } @@ -2334,8 +2371,8 @@ func (m *Msg) WriteToSendmailWithContext(ctx context.Context, sendmailPath strin // - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) NewReader() *Reader { reader := &Reader{} - buffer := bytes.Buffer{} - _, err := m.Write(&buffer) + buffer := bytes.NewBuffer(nil) + _, err := m.Write(buffer) if err != nil { reader.err = fmt.Errorf("failed to write Msg to Reader buffer: %w", err) } diff --git a/msg_nowin_test.go b/msg_nowin_test.go deleted file mode 100644 index 6cde71a..0000000 --- a/msg_nowin_test.go +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors -// -// SPDX-License-Identifier: MIT - -//go:build !windows -// +build !windows - -package mail - -import ( - "context" - "os" - "testing" - "time" -) - -// TestMsg_WriteToSendmailWithContext tests the WriteToSendmailWithContext() method of the Msg -func TestMsg_WriteToSendmailWithContext(t *testing.T) { - if os.Getenv("TEST_SKIP_SENDMAIL") != "" { - t.Skipf("TEST_SKIP_SENDMAIL variable is set. Skipping sendmail test") - } - tests := []struct { - name string - sp string - sf bool - }{ - {"Sendmail path: /dev/null", "/dev/null", true}, - {"Sendmail path: /bin/cat", "/bin/cat", true}, - {"Sendmail path: /is/invalid", "/is/invalid", true}, - {"Sendmail path: /bin/echo", "/bin/echo", false}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cfn := context.WithTimeout(context.Background(), time.Second*10) - defer cfn() - m.SetBodyString(TypeTextPlain, "Plain") - if err := m.WriteToSendmailWithContext(ctx, tt.sp); err != nil && !tt.sf { - t.Errorf("WriteToSendmailWithCommand() failed: %s", err) - } - m.Reset() - }) - } -} - -// TestMsg_WriteToSendmail will test the output to the local sendmail command -func TestMsg_WriteToSendmail(t *testing.T) { - if os.Getenv("TEST_SKIP_SENDMAIL") != "" { - t.Skipf("TEST_SKIP_SENDMAIL variable is set. Skipping sendmail test") - } - _, err := os.Stat(SendmailPath) - if err != nil { - t.Skipf("local sendmail command not found in expected path. Skipping") - } - - m := NewMsg() - _ = m.From("Toni Tester ") - _ = m.To(TestRcpt) - m.SetBodyString(TypeTextPlain, "This is a test") - if err := m.WriteToSendmail(); err != nil { - t.Errorf("WriteToSendmail failed: %s", err) - } -} - -func TestMsg_WriteToTempFileFailed(t *testing.T) { - m := NewMsg() - _ = m.From("Toni Tester ") - _ = m.To("Ellenor Tester ") - m.SetBodyString(TypeTextPlain, "This is a test") - - curTmpDir := os.Getenv("TMPDIR") - defer func() { - if err := os.Setenv("TMPDIR", curTmpDir); err != nil { - t.Errorf("failed to set TMPDIR environment variable: %s", err) - } - }() - - if err := os.Setenv("TMPDIR", "/invalid/directory/that/does/not/exist"); err != nil { - t.Errorf("failed to set TMPDIR environment variable: %s", err) - } - _, err := m.WriteToTempFile() - if err == nil { - t.Errorf("WriteToTempFile() did not fail as expected") - } -} diff --git a/msg_test.go b/msg_test.go index 7fcbd99..117e8e7 100644 --- a/msg_test.go +++ b/msg_test.go @@ -5,212 +5,6445 @@ package mail import ( - "bufio" "bytes" + "context" "embed" "errors" "fmt" - htpl "html/template" + ht "html/template" "io" - "net/mail" + "net" "os" - "sort" + "reflect" "strings" "testing" ttpl "text/template" "time" ) -//go:embed README.md -var efs embed.FS - -// TestNewMsg tests the NewMsg method -func TestNewMsg(t *testing.T) { - m := NewMsg() - var err error - if m.encoding != EncodingQP { - err = fmt.Errorf("default encoding is not Quoted-Prinable") - } - if m.charset != CharsetUTF8 { - err = fmt.Errorf("default charset is not UTF-8") - } - - if err != nil { - t.Errorf("NewMsg() failed: %s", err) - return - } -} - -// TestNewMsgCharset tests WithCharset and Msg.SetCharset -func TestNewMsgCharset(t *testing.T) { - tests := []struct { +var ( + charsetTests = []struct { name string value Charset want Charset }{ - {"charset is UTF-7", CharsetUTF7, "UTF-7"}, - {"charset is UTF-8", CharsetUTF8, "UTF-8"}, - {"charset is US-ASCII", CharsetASCII, "US-ASCII"}, - {"charset is ISO-8859-1", CharsetISO88591, "ISO-8859-1"}, - {"charset is ISO-8859-2", CharsetISO88592, "ISO-8859-2"}, - {"charset is ISO-8859-3", CharsetISO88593, "ISO-8859-3"}, - {"charset is ISO-8859-4", CharsetISO88594, "ISO-8859-4"}, - {"charset is ISO-8859-5", CharsetISO88595, "ISO-8859-5"}, - {"charset is ISO-8859-6", CharsetISO88596, "ISO-8859-6"}, - {"charset is ISO-8859-7", CharsetISO88597, "ISO-8859-7"}, - {"charset is ISO-8859-9", CharsetISO88599, "ISO-8859-9"}, - {"charset is ISO-8859-13", CharsetISO885913, "ISO-8859-13"}, - {"charset is ISO-8859-14", CharsetISO885914, "ISO-8859-14"}, - {"charset is ISO-8859-15", CharsetISO885915, "ISO-8859-15"}, - {"charset is ISO-8859-16", CharsetISO885916, "ISO-8859-16"}, - {"charset is ISO-2022-JP", CharsetISO2022JP, "ISO-2022-JP"}, - {"charset is ISO-2022-KR", CharsetISO2022KR, "ISO-2022-KR"}, - {"charset is windows-1250", CharsetWindows1250, "windows-1250"}, - {"charset is windows-1251", CharsetWindows1251, "windows-1251"}, - {"charset is windows-1252", CharsetWindows1252, "windows-1252"}, - {"charset is windows-1255", CharsetWindows1255, "windows-1255"}, - {"charset is windows-1256", CharsetWindows1256, "windows-1256"}, - {"charset is KOI8-R", CharsetKOI8R, "KOI8-R"}, - {"charset is KOI8-U", CharsetKOI8U, "KOI8-U"}, - {"charset is Big5", CharsetBig5, "Big5"}, - {"charset is GB18030", CharsetGB18030, "GB18030"}, - {"charset is GB2312", CharsetGB2312, "GB2312"}, - {"charset is TIS-620", CharsetTIS620, "TIS-620"}, - {"charset is EUC-KR", CharsetEUCKR, "EUC-KR"}, - {"charset is Shift_JIS", CharsetShiftJIS, "Shift_JIS"}, - {"charset is GBK", CharsetGBK, "GBK"}, - {"charset is Unknown", CharsetUnknown, "Unknown"}, + {"UTF-7", CharsetUTF7, "UTF-7"}, + {"UTF-8", CharsetUTF8, "UTF-8"}, + {"US-ASCII", CharsetASCII, "US-ASCII"}, + {"ISO-8859-1", CharsetISO88591, "ISO-8859-1"}, + {"ISO-8859-2", CharsetISO88592, "ISO-8859-2"}, + {"ISO-8859-3", CharsetISO88593, "ISO-8859-3"}, + {"ISO-8859-4", CharsetISO88594, "ISO-8859-4"}, + {"ISO-8859-5", CharsetISO88595, "ISO-8859-5"}, + {"ISO-8859-6", CharsetISO88596, "ISO-8859-6"}, + {"ISO-8859-7", CharsetISO88597, "ISO-8859-7"}, + {"ISO-8859-9", CharsetISO88599, "ISO-8859-9"}, + {"ISO-8859-13", CharsetISO885913, "ISO-8859-13"}, + {"ISO-8859-14", CharsetISO885914, "ISO-8859-14"}, + {"ISO-8859-15", CharsetISO885915, "ISO-8859-15"}, + {"ISO-8859-16", CharsetISO885916, "ISO-8859-16"}, + {"ISO-2022-JP", CharsetISO2022JP, "ISO-2022-JP"}, + {"ISO-2022-KR", CharsetISO2022KR, "ISO-2022-KR"}, + {"windows-1250", CharsetWindows1250, "windows-1250"}, + {"windows-1251", CharsetWindows1251, "windows-1251"}, + {"windows-1252", CharsetWindows1252, "windows-1252"}, + {"windows-1255", CharsetWindows1255, "windows-1255"}, + {"windows-1256", CharsetWindows1256, "windows-1256"}, + {"KOI8-R", CharsetKOI8R, "KOI8-R"}, + {"KOI8-U", CharsetKOI8U, "KOI8-U"}, + {"Big5", CharsetBig5, "Big5"}, + {"GB18030", CharsetGB18030, "GB18030"}, + {"GB2312", CharsetGB2312, "GB2312"}, + {"TIS-620", CharsetTIS620, "TIS-620"}, + {"EUC-KR", CharsetEUCKR, "EUC-KR"}, + {"Shift_JIS", CharsetShiftJIS, "Shift_JIS"}, + {"GBK", CharsetGBK, "GBK"}, + {"Unknown", CharsetUnknown, "Unknown"}, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMsg(WithCharset(tt.value), nil) - if m.charset != tt.want { - t.Errorf("WithCharset() failed. Expected: %s, got: %s", tt.want, m.charset) - } - m.SetCharset(CharsetUTF8) - if m.charset != CharsetUTF8 { - t.Errorf("SetCharset() failed. Expected: %s, got: %s", CharsetUTF8, m.charset) - } - m.SetCharset(tt.value) - if m.charset != tt.want { - t.Errorf("SetCharset() failed. Expected: %s, got: %s", tt.want, m.charset) - } - }) - } -} - -// TestNewMsgWithCharset tests WithEncoding and Msg.SetEncoding -func TestNewMsgWithEncoding(t *testing.T) { - tests := []struct { + encodingTests = []struct { name string value Encoding want Encoding }{ - {"encoding is Quoted-Printable", EncodingQP, "quoted-printable"}, - {"encoding is Base64", EncodingB64, "base64"}, - {"encoding is Unencoded 8-Bit", NoEncoding, "8bit"}, + {"Quoted-Printable", EncodingQP, "quoted-printable"}, + {"Base64", EncodingB64, "base64"}, + {"Unencoded (8-Bit)", NoEncoding, "8bit"}, + {"US-ASCII (7-Bit)", EncodingUSASCII, "7bit"}, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMsg(WithEncoding(tt.value)) - if m.encoding != tt.want { - t.Errorf("WithEncoding() failed. Expected: %s, got: %s", tt.want, m.encoding) - } - m.SetEncoding(NoEncoding) - if m.encoding != NoEncoding { - t.Errorf("SetEncoding() failed. Expected: %s, got: %s", NoEncoding, m.encoding) - } - m.SetEncoding(tt.want) - if m.encoding != tt.want { - t.Errorf("SetEncoding() failed. Expected: %s, got: %s", tt.want, m.encoding) - } - }) + pgpTests = []struct { + name string + value PGPType + }{ + {"No PGP encoding", NoPGP}, + {"PGP encrypted", PGPEncrypt}, + {"PGP signed", PGPSignature}, } -} - -// TestNewMsgWithMIMEVersion tests WithMIMEVersion and Msg.SetMIMEVersion -func TestNewMsgWithMIMEVersion(t *testing.T) { - tests := []struct { + boundaryTests = []struct { + name string + value string + }{ + {"test123", "test123"}, + {"empty string", ""}, + } + mimeTests = []struct { name string value MIMEVersion want MIMEVersion }{ - {"MIME version is 1.0", MIME10, "1.0"}, + {"1.0", MIME10, "1.0"}, + {"1.1 (not a valid version at this time)", MIMEVersion("1.1"), "1.1"}, } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMsg(WithMIMEVersion(tt.value)) - if m.mimever != tt.want { - t.Errorf("WithMIMEVersion() failed. Expected: %s, got: %s", tt.want, m.mimever) - } - m.mimever = "" - m.SetMIMEVersion(tt.value) - if m.mimever != tt.want { - t.Errorf("SetMIMEVersion() failed. Expected: %s, got: %s", tt.want, m.mimever) - } - }) - } -} - -// TestNewMsgWithBoundary tests WithBoundary and Msg.SetBoundary -func TestNewMsgWithBoundary(t *testing.T) { - tests := []struct { + contentTypeTests = []struct { name string + ctype ContentType + }{ + {"text/plain", TypeTextPlain}, + {"text/html", TypeTextHTML}, + {"application/octet-stream", TypeAppOctetStream}, + } + // Inspired by https://www.youtube.com/watch?v=xxX81WmXjPg&t=623s, yet, some assumptions in that video are + // incorrect for RFC5321/RFC5322 but rely on deprecated information from RFC822. The tests have been + // adjusted accordingly. + rfc5322Test = []struct { value string + valid bool }{ - {"boundary is test123", "test123"}, + {"hi@domain.tld", true}, + {"hi@", false}, + {`hi+there@domain.tld`, true}, + {"hi.there@domain.tld", true}, + {"hi.@domain.tld", false}, // Point at the end of localpart is not allowed + {"hi..there@domain.tld", false}, // Double point is not allowed + {`!#$%&'(-/=?'@domain.tld`, false}, // Invalid characters + {"hi*there@domain.tld", true}, // * is allowed in localpart + {`#$%!^/&@domain.tld`, true}, // Allowed localpart characters + {"h(a)i@domain.tld", false}, // Not allowed to use parenthesis + {"(hi)there@domain.tld", false}, // The (hi) at the start is a comment which is allowed in RFC822 but not in RFC5322 anymore + {"hithere@domain.tld(tld)", true}, // The (tld) at the end is also a comment + {"hi@there@domain.tld", false}, // Can't have two @ signs + {`"hi@there"@domain.tld`, true}, // Quoted @-signs are allowed + {`"hi there"@domain.tld`, true}, // Quoted whitespaces are allowed + {`" "@domain.tld`, true}, // Still valid, since quoted + {`"<\"@\".!#%$@domain.tld"`, false}, // Quoting with illegal characters is not allowed + {`<\"@\\".!#%$@domain.tld`, false}, // Still a bunch of random illegal characters + {`hi"@"there@domain.tld`, false}, // Quotes must be dot-seperated + {`"<\"@\\".!.#%$@domain.tld`, false}, // Quote is escaped and dot-seperated which would be RFC822 compliant, but not RFC5322 compliant + {`hi\ there@domain.tld`, false}, // Spaces must be quoted + {"hello@tld", true}, // TLD is enough + {`你好@域名.顶级域名`, true}, // We speak RFC6532 + {"1@23456789", true}, // Hypothetically valid, if somebody registers that TLD + {"1@[23456789]", false}, // While 23456789 is decimal for 1.101.236.21 it is not RFC5322 compliant } +) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMsg(WithBoundary(tt.value)) - if m.boundary != tt.value { - t.Errorf("WithBoundary() failed. Expected: %s, got: %s", tt.value, m.boundary) - } - m.boundary = "" - m.SetBoundary(tt.value) - if m.boundary != tt.value { - t.Errorf("SetBoundary() failed. Expected: %s, got: %s", tt.value, m.boundary) - } - }) - } +//go:embed testdata/attachment.txt testdata/embed.txt +var efs embed.FS + +func TestNewMsg(t *testing.T) { + t.Run("create new message", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if message.addrHeader == nil { + t.Errorf("address header map is nil") + } + if message.genHeader == nil { + t.Errorf("generic header map is nil") + } + if message.preformHeader == nil { + t.Errorf("preformatted header map is nil") + } + if message.charset != CharsetUTF8 { + t.Errorf("default charset for new Msg mismatch. Expected: %s, got: %s", CharsetUTF8, + message.charset) + } + if message.encoding != EncodingQP { + t.Errorf("default encoding for new Msg mismatch. Expected: %s, got: %s", EncodingQP, + message.encoding) + } + if message.mimever != MIME10 { + t.Errorf("default MIME version for new Msg mismatch. Expected: %s, got: %s", MIME10, + message.mimever) + } + if reflect.TypeOf(message.encoder).String() != "mime.WordEncoder" { + t.Errorf("default encoder for new Msg mismatch. Expected: %s, got: %s", "mime.WordEncoder", + reflect.TypeOf(message.encoder).String()) + } + if !strings.EqualFold(message.encoder.Encode(message.charset.String(), "ab12§$/"), + `=?UTF-8?q?ab12=C2=A7$/?=`) { + t.Errorf("default encoder for new Msg mismatch. QP encoded expected string: %s, got: %s", + `=?UTF-8?q?ab12=C2=A7$/?=`, message.encoder.Encode(message.charset.String(), "ab12§$/")) + } + }) + t.Run("new message with nil option", func(t *testing.T) { + message := NewMsg(nil) + if message == nil { + t.Fatal("message is nil") + } + }) + t.Run("new message with custom charsets", func(t *testing.T) { + for _, tt := range charsetTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg(WithCharset(tt.value), nil) + if message == nil { + t.Fatal("message is nil") + } + if message.charset != tt.want { + t.Fatalf("NewMsg(WithCharset(%s)) failed. Expected charset: %s, got: %s", tt.value, tt.want, + message.charset) + } + }) + } + }) + t.Run("new message with custom encoding", func(t *testing.T) { + for _, tt := range encodingTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg(WithEncoding(tt.value), nil) + if message == nil { + t.Fatal("message is nil") + } + if message.encoding != tt.want { + t.Errorf("NewMsg(WithEncoding(%s)) failed. Expected encoding: %s, got: %s", tt.value, + tt.want, message.encoding) + } + }) + } + }) + t.Run("new message with custom MIME version", func(t *testing.T) { + for _, tt := range mimeTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg(WithMIMEVersion(tt.value)) + if message == nil { + t.Fatal("message is nil") + } + if message.mimever != tt.want { + t.Errorf("NewMsg(WithMIMEVersion(%s)) failed. Expected MIME version: %s, got: %s", + tt.value, tt.want, message.mimever) + } + }) + } + }) + t.Run("new message with custom boundary", func(t *testing.T) { + for _, tt := range boundaryTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg(WithBoundary(tt.value)) + if message == nil { + t.Fatal("message is nil") + } + if message.boundary != tt.value { + t.Errorf("NewMsg(WithBoundary(%s)) failed. Expected boundary: %s, got: %s", tt.value, + tt.value, message.boundary) + } + }) + } + }) + t.Run("new message with custom PGP type", func(t *testing.T) { + for _, tt := range pgpTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg(WithPGPType(tt.value)) + if message == nil { + t.Fatal("message is nil") + } + if message.pgptype != tt.value { + t.Errorf("NewMsg(WithPGPType(%d)) failed. Expected PGP type: %d, got: %d", tt.value, + tt.value, message.pgptype) + } + }) + } + }) + t.Run("new message with middleware: uppercase", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if len(message.middlewares) != 0 { + t.Errorf("NewMsg() failed. Expected empty middlewares, got: %d", len(message.middlewares)) + } + message = NewMsg(WithMiddleware(uppercaseMiddleware{})) + if len(message.middlewares) != 1 { + t.Errorf("NewMsg(WithMiddleware(uppercaseMiddleware{})) failed. Expected 1 middleware, got: %d", + len(message.middlewares)) + } + message = NewMsg(WithMiddleware(uppercaseMiddleware{}), WithMiddleware(encodeMiddleware{})) + if len(message.middlewares) != 2 { + t.Errorf("NewMsg(WithMiddleware(uppercaseMiddleware{}),WithMiddleware(encodeMiddleware{})) "+ + "failed. Expected 2 middleware, got: %d", len(message.middlewares)) + } + }) + t.Run("new message without default user-agent", func(t *testing.T) { + message := NewMsg(WithNoDefaultUserAgent()) + if message == nil { + t.Fatal("message is nil") + } + if !message.noDefaultUserAgent { + t.Errorf("NewMsg(WithNoDefaultUserAgent()) failed. Expected noDefaultUserAgent to be true, got: %t", + message.noDefaultUserAgent) + } + }) } -// TestNewMsg_WithPGPType tests WithPGPType option -func TestNewMsg_WithPGPType(t *testing.T) { +func TestMsg_SetCharset(t *testing.T) { + t.Run("SetCharset on new message", func(t *testing.T) { + for _, tt := range charsetTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetCharset(tt.value) + if message.charset != tt.want { + t.Errorf("failed to set charset. Expected: %s, got: %s", tt.want, message.charset) + } + }) + } + }) + t.Run("SetCharset to override WithCharset", func(t *testing.T) { + message := NewMsg(WithCharset(CharsetUTF7)) + if message == nil { + t.Fatal("message is nil") + } + if message.charset != CharsetUTF7 { + t.Errorf("failed to set charset on message creation. Expected: %s, got: %s", CharsetUTF7, + message.charset) + } + message.SetCharset(CharsetUTF8) + if message.charset != CharsetUTF8 { + t.Errorf("failed to set charset. Expected: %s, got: %s", CharsetUTF8, message.charset) + } + }) +} + +func TestMsg_SetEncoding(t *testing.T) { + t.Run("SetEncoding on new message", func(t *testing.T) { + for _, tt := range encodingTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetEncoding(tt.value) + if message.encoding != tt.want { + t.Errorf("failed to set encoding. Expected: %s, got: %s", tt.want, message.encoding) + } + }) + } + }) + t.Run("SetEncoding to override WithEncoding", func(t *testing.T) { + message := NewMsg(WithEncoding(EncodingUSASCII)) + if message == nil { + t.Fatal("message is nil") + } + if message.encoding != EncodingUSASCII { + t.Errorf("failed to set encoding on message creation. Expected: %s, got: %s", EncodingUSASCII, + message.encoding) + } + message.SetEncoding(EncodingB64) + if message.encoding != EncodingB64 { + t.Errorf("failed to set encoding. Expected: %s, got: %s", EncodingB64, message.encoding) + } + }) +} + +func TestMsg_SetBoundary(t *testing.T) { + t.Run("SetBoundary on new message", func(t *testing.T) { + for _, tt := range boundaryTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBoundary(tt.value) + if message.boundary != tt.value { + t.Errorf("failed to set boundary. Expected: %s, got: %s", tt.value, message.boundary) + } + }) + } + }) + t.Run("SetBoundary to override WithBoundary", func(t *testing.T) { + message := NewMsg(WithBoundary("123Test")) + if message == nil { + t.Fatal("message is nil") + } + if message.boundary != "123Test" { + t.Errorf("failed to set boundary on message creation. Expected: %s, got: %s", "123Test", + message.boundary) + } + message.SetBoundary("test123") + if message.boundary != "test123" { + t.Errorf("failed to set boundary. Expected: %s, got: %s", "test123", message.boundary) + } + }) +} + +func TestMsg_SetMIMEVersion(t *testing.T) { + t.Run("SetMIMEVersion on new message", func(t *testing.T) { + for _, tt := range mimeTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetMIMEVersion(tt.value) + if message.mimever != tt.value { + t.Errorf("failed to set mime version. Expected: %s, got: %s", tt.value, message.mimever) + } + }) + } + }) + t.Run("SetMIMEVersion to override WithMIMEVersion", func(t *testing.T) { + message := NewMsg(WithMIMEVersion("1.1")) + if message == nil { + t.Fatal("message is nil") + } + if message.mimever != "1.1" { + t.Errorf("failed to set mime version on message creation. Expected: %s, got: %s", "1.1", + message.mimever) + } + message.SetMIMEVersion(MIME10) + if message.mimever != MIME10 { + t.Errorf("failed to set mime version. Expected: %s, got: %s", MIME10, message.mimever) + } + }) +} + +func TestMsg_SetPGPType(t *testing.T) { + t.Run("SetPGPType on new message", func(t *testing.T) { + for _, tt := range pgpTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetPGPType(tt.value) + if message.pgptype != tt.value { + t.Errorf("failed to set pgp type. Expected: %d, got: %d", tt.value, message.pgptype) + } + }) + } + }) + t.Run("SetPGPType to override WithPGPType", func(t *testing.T) { + message := NewMsg(WithPGPType(PGPSignature)) + if message == nil { + t.Fatal("message is nil") + } + if message.pgptype != PGPSignature { + t.Errorf("failed to set pgp type on message creation. Expected: %d, got: %d", PGPSignature, + message.pgptype) + } + message.SetPGPType(PGPEncrypt) + if message.pgptype != PGPEncrypt { + t.Errorf("failed to set pgp type. Expected: %d, got: %d", PGPEncrypt, message.pgptype) + } + }) +} + +func TestMsg_Encoding(t *testing.T) { + t.Run("Encoding returns expected string", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range encodingTests { + t.Run(tt.name, func(t *testing.T) { + message.SetEncoding(tt.value) + if message.Encoding() != tt.want.String() { + t.Errorf("failed to get encoding. Expected: %s, got: %s", tt.want.String(), message.Encoding()) + } + }) + } + }) +} + +func TestMsg_Charset(t *testing.T) { + t.Run("Charset returns expected string", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range charsetTests { + t.Run(tt.name, func(t *testing.T) { + message.SetCharset(tt.value) + if message.Charset() != tt.want.String() { + t.Errorf("failed to get charset. Expected: %s, got: %s", tt.want.String(), message.Charset()) + } + }) + } + }) +} + +func TestMsg_SetHeader(t *testing.T) { + t.Run("SetHeader on new message", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + //goland:noinspection GoDeprecation + message.SetHeader(tt.header, "test", "foo", "bar") + values, ok := message.genHeader[tt.header] + if !ok { + t.Fatalf("failed to set header, genHeader field for %s is not set", tt.header) + } + if len(values) != 3 { + t.Fatalf("failed to set header, genHeader value count for %s is %d, want: 3", + tt.header, len(values)) + } + if values[0] != "test" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + values[0], "test") + } + if values[1] != "foo" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + values[1], "foo") + } + if values[2] != "bar" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + values[1], "bar") + } + }) + } + }) +} + +func TestMsg_SetGenHeader(t *testing.T) { + t.Run("SetGenHeader on new message", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message.SetGenHeader(tt.header, "test", "foo", "bar") + values, ok := message.genHeader[tt.header] + if !ok { + t.Fatalf("failed to set header, genHeader field for %s is not set", tt.header) + } + if len(values) != 3 { + t.Fatalf("failed to set header, genHeader value count for %s is %d, want: 3", + tt.header, len(values)) + } + if values[0] != "test" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + values[0], "test") + } + if values[1] != "foo" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + values[1], "foo") + } + if values[2] != "bar" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + values[1], "bar") + } + }) + } + }) + t.Run("SetGenHeader with empty genHeaderMap", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.genHeader = nil + message.SetGenHeader(HeaderSubject, "test", "foo", "bar") + values, ok := message.genHeader[HeaderSubject] + if !ok { + t.Fatalf("failed to set header, genHeader field for %s is not set", HeaderSubject) + } + if len(values) != 3 { + t.Fatalf("failed to set header, genHeader value count for %s is %d, want: 3", + HeaderSubject, len(values)) + } + if values[0] != "test" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", HeaderSubject, + values[0], "test") + } + if values[1] != "foo" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", HeaderSubject, + values[1], "foo") + } + if values[2] != "bar" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", HeaderSubject, + values[1], "bar") + } + }) +} + +func TestMsg_SetHeaderPreformatted(t *testing.T) { + t.Run("SetHeaderPreformatted on new message", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + //goland:noinspection GoDeprecation + message.SetHeaderPreformatted(tt.header, "test") + value, ok := message.preformHeader[tt.header] + if !ok { + t.Fatalf("failed to set header, genHeader field for %s is not set", tt.header) + } + if value != "test" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + value, "test") + } + }) + } + }) +} + +func TestMsg_SetGenHeaderPreformatted(t *testing.T) { + t.Run("SetGenHeaderPreformatted on new message", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message.SetGenHeaderPreformatted(tt.header, "test") + value, ok := message.preformHeader[tt.header] + if !ok { + t.Fatalf("failed to set header, genHeader field for %s is not set", tt.header) + } + if value != "test" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", tt.header, + value, "test") + } + }) + } + }) + t.Run("SetGenHeaderPreformatted with empty preformHeader map", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.preformHeader = nil + message.SetGenHeaderPreformatted(HeaderSubject, "test") + value, ok := message.preformHeader[HeaderSubject] + if !ok { + t.Fatalf("failed to set header, genHeader field for %s is not set", HeaderSubject) + } + if value != "test" { + t.Errorf("failed to set header, genHeader value for %s is %s, want: %s", HeaderSubject, + value, "test") + } + }) +} + +func TestMsg_SetAddrHeader(t *testing.T) { + t.Run("SetAddrHeader with valid address without <>", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.SetAddrHeader(tt.header, "toni.tester@example.com"); err != nil { + t.Fatalf("failed to set address header, err: %s", err) + } + checkAddrHeader(t, message, tt.header, "SetAddrHeader", 0, 1, "toni.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeader with valid address with <>", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.SetAddrHeader(tt.header, ""); err != nil { + t.Fatalf("failed to set address header, err: %s", err) + } + checkAddrHeader(t, message, tt.header, "SetAddrHeader", 0, 1, "toni.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeader with valid address and name", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.SetAddrHeader(tt.header, fmt.Sprintf("%q <%s>", "Toni Tester", + "toni.tester@example.com")); err != nil { + t.Fatalf("failed to set address header, err: %s", err) + } + checkAddrHeader(t, message, tt.header, "SetAddrHeader", 0, 1, + "toni.tester@example.com", "Toni Tester") + }) + } + }) + t.Run("SetAddrHeader with multiple addresses", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + // From must only have one address + if tt.header == HeaderFrom { + return + } + + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.SetAddrHeader(tt.header, "toni.tester@example.com", + "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set address header, err: %s", err) + } + checkAddrHeader(t, message, tt.header, "SetAddrHeader", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, tt.header, "SetAddrHeader", 1, 2, "tina.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeader with multiple addresses but from addresses should only return the first one", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.SetAddrHeader(HeaderFrom, "toni.tester@example.com", + "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set address header, err: %s", err) + } + checkAddrHeader(t, message, HeaderFrom, "SetAddrHeader", 0, 1, "toni.tester@example.com", "") + }) + t.Run("SetAddrHeader with addrHeader map is nil", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.addrHeader = nil + if err := message.SetAddrHeader(HeaderFrom, "toni.tester@example.com", + "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set address header, err: %s", err) + } + checkAddrHeader(t, message, HeaderFrom, "SetAddrHeader", 0, 1, "toni.tester@example.com", "") + }) + t.Run("SetAddrHeader with invalid address", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.SetAddrHeader(HeaderFrom, "invalid"); err == nil { + t.Fatalf("SetAddrHeader with invalid address should fail") + } + }) + } + }) +} + +func TestMsg_SetAddrHeaderIgnoreInvalid(t *testing.T) { + t.Run("SetAddrHeaderIgnoreInvalid with valid address without <>", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetAddrHeaderIgnoreInvalid(tt.header, "toni.tester@example.com") + checkAddrHeader(t, message, tt.header, "SetAddrHeaderIgnoreInvalid", 0, 1, + "toni.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeaderIgnoreInvalid with valid address with <>", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetAddrHeaderIgnoreInvalid(tt.header, "") + checkAddrHeader(t, message, tt.header, "SetAddrHeaderIgnoreInvalid", 0, 1, + "toni.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeaderIgnoreInvalid with multiple valid addresses", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + // From must only have one address + if tt.header == HeaderFrom { + return + } + + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetAddrHeaderIgnoreInvalid(tt.header, "toni.tester@example.com", + "tina.tester@example.com") + checkAddrHeader(t, message, tt.header, "SetAddrHeaderIgnoreInvalid", 0, 2, + "toni.tester@example.com", "") + checkAddrHeader(t, message, tt.header, "SetAddrHeaderIgnoreInvalid", 1, 2, + "tina.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeaderIgnoreInvalid with multiple addresses valid and invalid", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + // From must only have one address + if tt.header == HeaderFrom { + return + } + + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetAddrHeaderIgnoreInvalid(tt.header, "toni.tester@example.com", + "invalid", "tina.tester@example.com") + checkAddrHeader(t, message, tt.header, "SetAddrHeaderIgnoreInvalid", 0, 2, + "toni.tester@example.com", "") + checkAddrHeader(t, message, tt.header, "SetAddrHeaderIgnoreInvalid", 1, 2, + "tina.tester@example.com", "") + }) + } + }) + t.Run("SetAddrHeaderIgnoreInvalid with addrHeader map is nil", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.addrHeader = nil + message.SetAddrHeaderIgnoreInvalid(HeaderFrom, "toni.tester@example.com", "tina.tester@example.com") + checkAddrHeader(t, message, HeaderFrom, "SetAddrHeaderIgnoreInvalid", 0, 1, "toni.tester@example.com", "") + }) + t.Run("SetAddrHeaderIgnoreInvalid with invalid addresses only", func(t *testing.T) { + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetAddrHeaderIgnoreInvalid(HeaderTo, "invalid", "foo") + addresses, ok := message.addrHeader[HeaderTo] + if !ok { + t.Fatalf("failed to set address header, addrHeader field for %s is not set", HeaderTo) + } + if len(addresses) != 0 { + t.Fatalf("failed to set address header, addrHeader value count for To is: %d, want: 0", + len(addresses)) + } + }) + } + }) +} + +func TestMsg_EnvelopeFrom(t *testing.T) { + t.Run("EnvelopeFrom with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set envelope from: %s", err) + } + checkAddrHeader(t, message, HeaderEnvelopeFrom, "EnvelopeFrom", 0, 1, "toni.tester@example.com", "") + }) + t.Run("EnvelopeFrom with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom("invalid"); err == nil { + t.Fatalf("EnvelopeFrom should fail with invalid address") + } + }) + t.Run("EnvelopeFrom with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom(""); err == nil { + t.Fatalf("EnvelopeFrom should fail with invalid address") + } + }) +} + +func TestMsg_EnvelopeFromFormat(t *testing.T) { + t.Run("EnvelopeFromFormat with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFromFormat("Toni Tester", "toni.tester@example.com"); err != nil { + t.Fatalf("failed to set envelope From: %s", err) + } + checkAddrHeader(t, message, HeaderEnvelopeFrom, "FromFormat", 0, 1, "toni.tester@example.com", "Toni Tester") + }) + t.Run("EnvelopeFromFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFromFormat("Toni Tester", "invalid"); err == nil { + t.Fatalf("EnvelopeFromFormat should fail with invalid address") + } + }) + t.Run("EnvelopeFromFormat with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFromFormat("", ""); err == nil { + t.Fatalf("EnvelopeFromFormat should fail with invalid address") + } + }) +} + +func TestMsg_From(t *testing.T) { + t.Run("From with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set From: %s", err) + } + checkAddrHeader(t, message, HeaderFrom, "From", 0, 1, "toni.tester@example.com", "") + }) + t.Run("From with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("invalid"); err == nil { + t.Fatalf("From should fail with invalid address") + } + }) + t.Run("From with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From(""); err == nil { + t.Fatalf("From should fail with invalid address") + } + }) + t.Run("From with different RFC5322 addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range rfc5322Test { + t.Run(tt.value, func(t *testing.T) { + err := message.From(tt.value) + if err != nil && tt.valid { + t.Errorf("From on address %s should succeed, but failed with: %s", tt.value, err) + } + if err == nil && !tt.valid { + t.Errorf("From on address %s should fail, but succeeded", tt.value) + } + }) + } + }) +} + +func TestMsg_FromFormat(t *testing.T) { + t.Run("FromFormat with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.FromFormat("Toni Tester", "toni.tester@example.com"); err != nil { + t.Fatalf("failed to set From: %s", err) + } + checkAddrHeader(t, message, HeaderFrom, "FromFormat", 0, 1, "toni.tester@example.com", "Toni Tester") + }) + t.Run("FromFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.FromFormat("Toni Tester", "invalid"); err == nil { + t.Fatalf("FromFormat should fail with invalid address") + } + }) + t.Run("FromFormat with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.FromFormat("", ""); err == nil { + t.Fatalf("FromFormat should fail with invalid address") + } + }) +} + +func TestMsg_To(t *testing.T) { + t.Run("To with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set To: %s", err) + } + checkAddrHeader(t, message, HeaderTo, "To", 0, 1, "toni.tester@example.com", "") + }) + t.Run("To with multiple valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set To: %s", err) + } + checkAddrHeader(t, message, HeaderTo, "To", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderTo, "To", 1, 2, "tina.tester@example.com", "") + }) + t.Run("To with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("invalid"); err == nil { + t.Fatalf("To should fail with invalid address") + } + }) + t.Run("To with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To(""); err == nil { + t.Fatalf("To should fail with invalid address") + } + }) + t.Run("To with different RFC5322 addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range rfc5322Test { + t.Run(tt.value, func(t *testing.T) { + err := message.To(tt.value) + if err != nil && tt.valid { + t.Errorf("To on address %s should succeed, but failed with: %s", tt.value, err) + } + if err == nil && !tt.valid { + t.Errorf("To on address %s should fail, but succeeded", tt.value) + } + }) + } + }) +} + +func TestMsg_AddTo(t *testing.T) { + t.Run("AddTo with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set To: %s", err) + } + if err := message.AddTo("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional To: %s", err) + } + checkAddrHeader(t, message, HeaderTo, "AddTo", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderTo, "AddTo", 1, 2, "tina.tester@example.com", "") + }) + t.Run("AddTo with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set To: %s", err) + } + if err := message.AddTo("invalid"); err == nil { + t.Errorf("AddTo should fail with invalid address") + } + checkAddrHeader(t, message, HeaderTo, "AddTo", 0, 1, "toni.tester@example.com", "") + }) +} + +func TestMsg_AddToFormat(t *testing.T) { + t.Run("AddToFormat with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set To: %s", err) + } + if err := message.AddToFormat("Tina Tester", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional To: %s", err) + } + checkAddrHeader(t, message, HeaderTo, "AddToFormat", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderTo, "AddToFormat", 1, 2, "tina.tester@example.com", "Tina Tester") + }) + t.Run("AddToFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set To: %s", err) + } + if err := message.AddToFormat("Invalid", "invalid"); err == nil { + t.Errorf("AddToFormat should fail with invalid address") + } + checkAddrHeader(t, message, HeaderTo, "AddToFormat", 0, 1, "toni.tester@example.com", "") + }) +} + +func TestMsg_ToIgnoreInvalid(t *testing.T) { + t.Run("ToIgnoreInvalid with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.ToIgnoreInvalid("toni.tester@example.com") + checkAddrHeader(t, message, HeaderTo, "ToIgnoreInvalid", 0, 1, "toni.tester@example.com", "") + }) + t.Run("ToIgnoreInvalid with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.ToIgnoreInvalid("invalid") + addresses, ok := message.addrHeader[HeaderTo] + if !ok { + t.Fatalf("failed to set ToIgnoreInvalid, addrHeader field is not set") + } + if len(addresses) != 0 { + t.Fatalf("failed to set ToIgnoreInvalid, addrHeader value count is: %d, want: 0", len(addresses)) + } + }) + t.Run("ToIgnoreInvalid with valid and invalid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.ToIgnoreInvalid("toni.tester@example.com", "invalid", "tina.tester@example.com") + checkAddrHeader(t, message, HeaderTo, "ToIgnoreInvalid", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderTo, "ToIgnoreInvalid", 1, 2, "tina.tester@example.com", "") + }) +} + +func TestMsg_ToFromString(t *testing.T) { + t.Run("ToFromString with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ToFromString(`toni.tester@example.com,`); err != nil { + t.Fatalf("failed to set ToFromString: %s", err) + } + checkAddrHeader(t, message, HeaderTo, "ToFromString", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderTo, "ToFromString", 1, 2, "tina.tester@example.com", "") + }) + t.Run("ToFromString with valid addresses and empty fields", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ToFromString(`toni.tester@example.com ,,`); err != nil { + t.Fatalf("failed to set ToFromString: %s", err) + } + checkAddrHeader(t, message, HeaderTo, "ToFromString", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderTo, "ToFromString", 1, 2, "tina.tester@example.com", "") + }) +} + +func TestMsg_Cc(t *testing.T) { + t.Run("Cc with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Cc: %s", err) + } + checkAddrHeader(t, message, HeaderCc, "Cc", 0, 1, "toni.tester@example.com", "") + }) + t.Run("Cc with multiple valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set Cc: %s", err) + } + checkAddrHeader(t, message, HeaderCc, "Cc", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderCc, "Cc", 1, 2, "tina.tester@example.com", "") + }) + t.Run("Cc with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("invalid"); err == nil { + t.Fatalf("Cc should fail with invalid address") + } + }) + t.Run("Cc with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc(""); err == nil { + t.Fatalf("Cc should fail with invalid address") + } + }) + t.Run("Cc with different RFC5322 addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range rfc5322Test { + t.Run(tt.value, func(t *testing.T) { + err := message.Cc(tt.value) + if err != nil && tt.valid { + t.Errorf("Cc on address %s should succeed, but failed with: %s", tt.value, err) + } + if err == nil && !tt.valid { + t.Errorf("Cc on address %s should fail, but succeeded", tt.value) + } + }) + } + }) +} + +func TestMsg_AddCc(t *testing.T) { + t.Run("AddCc with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Cc: %s", err) + } + if err := message.AddCc("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional Cc: %s", err) + } + checkAddrHeader(t, message, HeaderCc, "AddCc", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderCc, "AddCc", 1, 2, "tina.tester@example.com", "") + }) + t.Run("AddCc with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Cc: %s", err) + } + if err := message.AddCc("invalid"); err == nil { + t.Errorf("AddCc should fail with invalid address") + } + checkAddrHeader(t, message, HeaderCc, "AddCc", 0, 1, "toni.tester@example.com", "") + }) +} + +func TestMsg_AddCcFormat(t *testing.T) { + t.Run("AddCcFormat with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Cc: %s", err) + } + if err := message.AddCcFormat("Tina Tester", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional Cc: %s", err) + } + checkAddrHeader(t, message, HeaderCc, "AddCcFormat", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderCc, "AddCcFormat", 1, 2, "tina.tester@example.com", "Tina Tester") + }) + t.Run("AddCcFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Cc: %s", err) + } + if err := message.AddCcFormat("Invalid", "invalid"); err == nil { + t.Errorf("AddCcFormat should fail with invalid address") + } + checkAddrHeader(t, message, HeaderCc, "AddCcFormat", 0, 1, "toni.tester@example.com", "") + }) +} + +func TestMsg_CcIgnoreInvalid(t *testing.T) { + t.Run("CcIgnoreInvalid with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.CcIgnoreInvalid("toni.tester@example.com") + checkAddrHeader(t, message, HeaderCc, "CcIgnoreInvalid", 0, 1, "toni.tester@example.com", "") + }) + t.Run("CcIgnoreInvalid with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.CcIgnoreInvalid("invalid") + addresses, ok := message.addrHeader[HeaderCc] + if !ok { + t.Fatalf("failed to set CcIgnoreInvalid, addrHeader field is not set") + } + if len(addresses) != 0 { + t.Fatalf("failed to set CcIgnoreInvalid, addrHeader value count is: %d, want: 0", len(addresses)) + } + }) + t.Run("CcIgnoreInvalid with valid and invalid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.CcIgnoreInvalid("toni.tester@example.com", "invalid", "tina.tester@example.com") + checkAddrHeader(t, message, HeaderCc, "CcIgnoreInvalid", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderCc, "CcIgnoreInvalid", 1, 2, "tina.tester@example.com", "") + }) +} + +func TestMsg_CcFromString(t *testing.T) { + t.Run("CcFromString with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.CcFromString(`toni.tester@example.com,`); err != nil { + t.Fatalf("failed to set CcFromString: %s", err) + } + checkAddrHeader(t, message, HeaderCc, "CcFromString", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderCc, "CcFromString", 1, 2, "tina.tester@example.com", "") + }) + t.Run("CcFromString with valid addresses and empty fields", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.CcFromString(`toni.tester@example.com ,,`); err != nil { + t.Fatalf("failed to set CcFromString: %s", err) + } + checkAddrHeader(t, message, HeaderCc, "CcFromString", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderCc, "CcFromString", 1, 2, "tina.tester@example.com", "") + }) +} + +func TestMsg_Bcc(t *testing.T) { + t.Run("Bcc with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Bcc: %s", err) + } + checkAddrHeader(t, message, HeaderBcc, "Bcc", 0, 1, "toni.tester@example.com", "") + }) + t.Run("Bcc with multiple valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set Bcc: %s", err) + } + checkAddrHeader(t, message, HeaderBcc, "Bcc", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderBcc, "Bcc", 1, 2, "tina.tester@example.com", "") + }) + t.Run("Bcc with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("invalid"); err == nil { + t.Fatalf("Bcc should fail with invalid address") + } + }) + t.Run("Bcc with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc(""); err == nil { + t.Fatalf("Bcc should fail with invalid address") + } + }) + t.Run("Bcc with different RFC5322 addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range rfc5322Test { + t.Run(tt.value, func(t *testing.T) { + err := message.Bcc(tt.value) + if err != nil && tt.valid { + t.Errorf("Bcc on address %s should succeed, but failed with: %s", tt.value, err) + } + if err == nil && !tt.valid { + t.Errorf("Bcc on address %s should fail, but succeeded", tt.value) + } + }) + } + }) +} + +func TestMsg_AddBcc(t *testing.T) { + t.Run("AddBcc with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Bcc: %s", err) + } + if err := message.AddBcc("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional Bcc: %s", err) + } + checkAddrHeader(t, message, HeaderBcc, "AddBcc", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderBcc, "AddBcc", 1, 2, "tina.tester@example.com", "") + }) + t.Run("AddBcc with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Bcc: %s", err) + } + if err := message.AddBcc("invalid"); err == nil { + t.Errorf("AddBcc should fail with invalid address") + } + checkAddrHeader(t, message, HeaderBcc, "AddBcc", 0, 1, "toni.tester@example.com", "") + }) +} + +func TestMsg_AddBccFormat(t *testing.T) { + t.Run("AddBccFormat with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Bcc: %s", err) + } + if err := message.AddBccFormat("Tina Tester", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional Bcc: %s", err) + } + checkAddrHeader(t, message, HeaderBcc, "AddBccFormat", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderBcc, "AddBccFormat", 1, 2, "tina.tester@example.com", "Tina Tester") + }) + t.Run("AddBccFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set Bcc: %s", err) + } + if err := message.AddBccFormat("Invalid", "invalid"); err == nil { + t.Errorf("AddBccFormat should fail with invalid address") + } + checkAddrHeader(t, message, HeaderBcc, "AddBccFormat", 0, 1, "toni.tester@example.com", "") + }) +} + +func TestMsg_BccIgnoreInvalid(t *testing.T) { + t.Run("BccIgnoreInvalid with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.BccIgnoreInvalid("toni.tester@example.com") + checkAddrHeader(t, message, HeaderBcc, "BccIgnoreInvalid", 0, 1, "toni.tester@example.com", "") + }) + t.Run("BccIgnoreInvalid with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.BccIgnoreInvalid("invalid") + addresses, ok := message.addrHeader[HeaderBcc] + if !ok { + t.Fatalf("failed to set BccIgnoreInvalid, addrHeader field is not set") + } + if len(addresses) != 0 { + t.Fatalf("failed to set BccIgnoreInvalid, addrHeader value count is: %d, want: 0", len(addresses)) + } + }) + t.Run("BccIgnoreInvalid with valid and invalid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.BccIgnoreInvalid("toni.tester@example.com", "invalid", "tina.tester@example.com") + checkAddrHeader(t, message, HeaderBcc, "BccIgnoreInvalid", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderBcc, "BccIgnoreInvalid", 1, 2, "tina.tester@example.com", "") + }) +} + +func TestMsg_BccFromString(t *testing.T) { + t.Run("BccFromString with valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.BccFromString(`toni.tester@example.com,`); err != nil { + t.Fatalf("failed to set BccFromString: %s", err) + } + checkAddrHeader(t, message, HeaderBcc, "BccFromString", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderBcc, "BccFromString", 1, 2, "tina.tester@example.com", "") + }) + t.Run("BccFromString with valid addresses and empty fields", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.BccFromString(`toni.tester@example.com ,,`); err != nil { + t.Fatalf("failed to set BccFromString: %s", err) + } + checkAddrHeader(t, message, HeaderBcc, "BccFromString", 0, 2, "toni.tester@example.com", "") + checkAddrHeader(t, message, HeaderBcc, "BccFromString", 1, 2, "tina.tester@example.com", "") + }) +} + +func TestMsg_ReplyTo(t *testing.T) { + t.Run("ReplyTo with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ReplyTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set ReplyTo: %s", err) + } + checkGenHeader(t, message, HeaderReplyTo, "ReplyTo", 0, 1, "") + }) + t.Run("ReplyTo with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ReplyTo("invalid"); err == nil { + t.Fatalf("ReplyTo should fail with invalid address") + } + }) + t.Run("ReplyTo with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ReplyTo(""); err == nil { + t.Fatalf("ReplyTo should fail with invalid address") + } + }) + t.Run("ReplyTo with different RFC5322 addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range rfc5322Test { + t.Run(tt.value, func(t *testing.T) { + err := message.ReplyTo(tt.value) + if err != nil && tt.valid { + t.Errorf("ReplyTo on address %s should succeed, but failed with: %s", tt.value, err) + } + if err == nil && !tt.valid { + t.Errorf("ReplyTo on address %s should fail, but succeeded", tt.value) + } + }) + } + }) +} + +func TestMsg_ReplyToFormat(t *testing.T) { + t.Run("ReplyToFormat with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ReplyToFormat("Tina Tester", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set ReplyTo: %s", err) + } + checkGenHeader(t, message, HeaderReplyTo, "ReplyToFormat", 0, 1, `"Tina Tester" `) + }) + t.Run("ReplyToFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.ReplyToFormat("Invalid", "invalid"); err == nil { + t.Errorf("ReplyToFormat should fail with invalid address") + } + }) +} + +func TestMsg_Subject(t *testing.T) { tests := []struct { - name string - pt PGPType - hpt bool + name string + subject string + want string }{ - {"Not a PGP encoded message", NoPGP, false}, - {"PGP encrypted message", PGPEncrypt, true}, - {"PGP signed message", PGPSignature, true}, + {"Normal latin characters", "Hello world!", "Hello world!"}, + {"Empty string", "", ""}, + { + "European umlaut characters", "Héllô wörld! äöüß", + "=?UTF-8?q?H=C3=A9ll=C3=B4_w=C3=B6rld!_=C3=A4=C3=B6=C3=BC=C3=9F?=", + }, + { + "Japanese characters", `これはテスト対象です。`, + `=?UTF-8?q?=E3=81=93=E3=82=8C=E3=81=AF=E3=83=86=E3=82=B9=E3=83=88=E5=AF=BE?= ` + + `=?UTF-8?q?=E8=B1=A1=E3=81=A7=E3=81=99=E3=80=82?=`, + }, + { + "Simplified chinese characters", `这是一个测试主题`, + `=?UTF-8?q?=E8=BF=99=E6=98=AF=E4=B8=80=E4=B8=AA=E6=B5=8B=E8=AF=95=E4=B8=BB?= ` + + `=?UTF-8?q?=E9=A2=98?=`, + }, + { + "Cyrillic characters", `Это испытуемый`, + `=?UTF-8?q?=D0=AD=D1=82=D0=BE_=D0=B8=D1=81=D0=BF=D1=8B=D1=82=D1=83=D0=B5?= ` + + `=?UTF-8?q?=D0=BC=D1=8B=D0=B9?=`, + }, + {"Emoji characters", `New Offer 🚀`, `=?UTF-8?q?New_Offer_=F0=9F=9A=80?=`}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - m := NewMsg(WithPGPType(tt.pt)) - if m.pgptype != tt.pt { - t.Errorf("WithPGPType() failed. Expected: %d, got: %d", tt.pt, m.pgptype) - } - m.pgptype = 99 - m.SetPGPType(tt.pt) - if m.pgptype != tt.pt { - t.Errorf("SetPGPType() failed. Expected: %d, got: %d", tt.pt, m.pgptype) - } - if m.hasPGPType() != tt.hpt { - t.Errorf("hasPGPType() failed. Expected %t, got: %t", tt.hpt, m.hasPGPType()) + message := NewMsg() + if message == nil { + t.Fatal("message is nil") } + message.Subject(tt.subject) + checkGenHeader(t, message, HeaderSubject, "Subject", 0, 1, tt.want) }) } } +func TestMsg_SetMessageID(t *testing.T) { + t.Run("SetMessageID randomness", func(t *testing.T) { + var mids []string + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for i := 0; i < 50_000; i++ { + message.SetMessageID() + mid := message.GetMessageID() + mids = append(mids, mid) + } + c := make(map[string]int) + for i := range mids { + c[mids[i]]++ + } + for k, v := range c { + if v > 1 { + t.Errorf("MessageID randomness not given. MessageID %q was generated %d times", k, v) + } + } + }) +} + +func TestMsg_GetMessageID(t *testing.T) { + t.Run("GetMessageID with normal IDs", func(t *testing.T) { + tests := []struct { + msgid string + want string + }{ + {"this.is.a.test", ""}, + {"12345.6789@domain.com", "<12345.6789@domain.com>"}, + {"abcd1234@sub.domain.com", ""}, + {"uniqeID-123@domain.co.tld", ""}, + {"2024_10_26192300@domain.tld", "<2024_10_26192300@domain.tld>"}, + } + for _, tt := range tests { + t.Run(tt.msgid, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetMessageIDWithValue(tt.msgid) + msgid := message.GetMessageID() + if !strings.EqualFold(tt.want, msgid) { + t.Errorf("GetMessageID() failed. Want: %s, got: %s", tt.want, msgid) + } + }) + } + }) + t.Run("GetMessageID no messageID set should return an empty string", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + msgid := message.GetMessageID() + if msgid != "" { + t.Errorf("GetMessageID() failed. Want: empty string, got: %s", msgid) + } + }) +} + +func TestMsg_SetMessageIDWithValue(t *testing.T) { + // We have already covered SetMessageIDWithValue in SetMessageID and GetMessageID + t.Log("SetMessageIDWithValue is fully covered by TestMsg_GetMessageID") +} + +func TestMsg_SetBulk(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBulk() + checkGenHeader(t, message, HeaderPrecedence, "SetBulk", 0, 1, "bulk") + checkGenHeader(t, message, HeaderXAutoResponseSuppress, "Bulk", 0, 1, "All") +} + +func TestMsg_SetDate(t *testing.T) { + t.Run("SetDate and compare date down to the minute", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + + message.SetDate() + values, ok := message.genHeader[HeaderDate] + if !ok { + t.Fatal("failed to set SetDate, genHeader field is not set") + } + if len(values) != 1 { + t.Fatalf("failed to set SetDate, genHeader value count is: %d, want: %d", len(values), 1) + } + date := values[0] + parsed, err := time.Parse(time.RFC1123Z, date) + if err != nil { + t.Fatalf("SetDate failed, failed to parse retrieved date: %s, error: %s", date, err) + } + now := time.Now() + nowNoSec := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location()) + parsedNoSec := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), + 0, 0, parsed.Location()) + if !nowNoSec.Equal(parsedNoSec) { + t.Errorf("SetDate failed, retrieved date mismatch, got: %s, want: %s", parsedNoSec.String(), + nowNoSec.String()) + } + }) +} + +func TestMsg_SetDateWithValue(t *testing.T) { + t.Run("SetDateWithValue and compare date down to the second", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + + now := time.Now() + message.SetDateWithValue(now) + values, ok := message.genHeader[HeaderDate] + if !ok { + t.Fatal("failed to set SetDate, genHeader field is not set") + } + if len(values) != 1 { + t.Fatalf("failed to set SetDate, genHeader value count is: %d, want: %d", len(values), 1) + } + date := values[0] + parsed, err := time.Parse(time.RFC1123Z, date) + if err != nil { + t.Fatalf("SetDate failed, failed to parse retrieved date: %s, error: %s", date, err) + } + if !strings.EqualFold(parsed.Format(time.RFC1123Z), now.Format(time.RFC1123Z)) { + t.Errorf("SetDate failed, retrieved date mismatch, got: %s, want: %s", now.Format(time.RFC1123Z), + parsed.Format(time.RFC1123Z)) + } + }) +} + +func TestMsg_SetImportance(t *testing.T) { + tests := []struct { + name string + importance Importance + }{ + {"Non-Urgent", ImportanceNonUrgent}, + {"Low", ImportanceLow}, + {"Normal", ImportanceNormal}, + {"High", ImportanceHigh}, + {"Urgent", ImportanceUrgent}, + {"Unknown", 9}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetImportance(tt.importance) + if tt.importance == ImportanceNormal { + t.Log("ImportanceNormal is does currently not set any values") + return + } + checkGenHeader(t, message, HeaderImportance, "SetImportance", 0, 1, tt.importance.String()) + checkGenHeader(t, message, HeaderPriority, "SetImportance", 0, 1, tt.importance.NumString()) + checkGenHeader(t, message, HeaderXPriority, "SetImportance", 0, 1, tt.importance.XPrioString()) + checkGenHeader(t, message, HeaderXMSMailPriority, "SetImportance", 0, 1, tt.importance.NumString()) + }) + } +} + +func TestMsg_SetOrganization(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetOrganization("ACME Inc.") + checkGenHeader(t, message, HeaderOrganization, "SetOrganization", 0, 1, "ACME Inc.") +} + +func TestMsg_SetUserAgent(t *testing.T) { + t.Run("SetUserAgent with value", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetUserAgent("go-mail test suite") + checkGenHeader(t, message, HeaderUserAgent, "SetUserAgent", 0, 1, "go-mail test suite") + checkGenHeader(t, message, HeaderXMailer, "SetUserAgent", 0, 1, "go-mail test suite") + }) + t.Run("Message without SetUserAgent should provide default agent", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + want := fmt.Sprintf("go-mail v%s // https://github.com/wneessen/go-mail", VERSION) + message.checkUserAgent() + checkGenHeader(t, message, HeaderUserAgent, "SetUserAgent", 0, 1, want) + checkGenHeader(t, message, HeaderXMailer, "SetUserAgent", 0, 1, want) + }) +} + +func TestMsg_IsDelivered(t *testing.T) { + t.Run("IsDelivered on unsent message", func(t *testing.T) { + message := testMessage(t) + if message.IsDelivered() { + t.Error("IsDelivered on unsent message should return false") + } + }) + t.Run("IsDelivered on sent message", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + + if !message.IsDelivered() { + t.Error("IsDelivered on sent message should return true") + } + }) + t.Run("IsDelivered on failed message delivery (DATA close)", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataClose: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + if message.IsDelivered() { + t.Error("IsDelivered on failed message delivery should return false") + } + }) + t.Run("IsDelivered on failed message delivery (final RESET)", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnReset: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + if !message.IsDelivered() { + t.Error("IsDelivered on sent message should return true") + } + }) +} + +func TestMsg_RequestMDNTo(t *testing.T) { + t.Run("RequestMDNTo with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNTo", 0, 1, "") + }) + t.Run("RequestMDNTo with valid address and nil-genHeader", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.genHeader = nil + if err := message.RequestMDNTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNTo", 0, 1, "") + }) + t.Run("RequestMDNTo with multiple valid addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNTo", 0, 2, "") + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNTo", 1, 2, "") + }) + t.Run("RequestMDNTo with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("invalid"); err == nil { + t.Fatalf("RequestMDNTo should fail with invalid address") + } + }) + t.Run("RequestMDNTo with empty string should fail", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo(""); err == nil { + t.Fatalf("RequestMDNTo should fail with invalid address") + } + }) + t.Run("RequestMDNTo with different RFC5322 addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range rfc5322Test { + t.Run(tt.value, func(t *testing.T) { + err := message.RequestMDNTo(tt.value) + if err != nil && tt.valid { + t.Errorf("RequestMDNTo on address %s should succeed, but failed with: %s", tt.value, err) + } + if err == nil && !tt.valid { + t.Errorf("RequestMDNTo on address %s should fail, but succeeded", tt.value) + } + }) + } + }) +} + +func TestMsg_RequestMDNToFormat(t *testing.T) { + t.Run("RequestMDNToFormat with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNToFormat("Toni Tester", "toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNToFormat: %s", err) + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNToFormat", 0, 1, + `"Toni Tester" `) + }) + t.Run("RequestMDNToFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNToFormat("invalid", "invalid"); err == nil { + t.Fatalf("RequestMDNToFormat should fail with invalid address") + } + }) +} + +func TestMsg_RequestMDNAddTo(t *testing.T) { + t.Run("RequestMDNAddTo with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + if err := message.RequestMDNAddTo("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNAddTo: %s", err) + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNAddTo", 0, 2, + ``) + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNAddTo", 1, 2, + ``) + }) + t.Run("RequestMDNAddTo with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + if err := message.RequestMDNAddTo("invalid"); err == nil { + t.Errorf("RequestMDNAddTo should fail with invalid address") + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNAddTo", 0, 1, + ``) + }) +} + +func TestMsg_RequestMDNAddToFormat(t *testing.T) { + t.Run("RequestMDNAddToFormat with valid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + if err := message.RequestMDNAddToFormat("Tina Tester", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNAddToFormat: %s", err) + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNAddToFormat", 0, 2, + ``) + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNAddToFormat", 1, 2, + `"Tina Tester" `) + }) + t.Run("RequestMDNAddToFormat with invalid address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.RequestMDNTo("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set RequestMDNTo: %s", err) + } + if err := message.RequestMDNAddToFormat("invalid", "invalid"); err == nil { + t.Errorf("RequestMDNAddToFormat should fail with invalid address") + } + checkGenHeader(t, message, HeaderDispositionNotificationTo, "RequestMDNAddToFormat", 0, 1, + ``) + }) +} + +func TestMsg_GetSender(t *testing.T) { + t.Run("GetSender with envelope from only (no full address)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set envelope from address: %s", err) + } + sender, err := message.GetSender(false) + if err != nil { + t.Errorf("failed to get sender: %s", err) + } + if !strings.EqualFold(sender, "toni.tester@example.com") { + t.Errorf("expected sender not returned. Want: %s, got: %s", "toni.tester@example.com", sender) + } + }) + t.Run("GetSender with envelope from only (full address)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set envelope from address: %s", err) + } + sender, err := message.GetSender(true) + if err != nil { + t.Errorf("failed to get sender: %s", err) + } + if !strings.EqualFold(sender, "") { + t.Errorf("expected sender not returned. Want: %s, got: %s", "", sender) + } + }) + t.Run("GetSender with from only (no full address)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + sender, err := message.GetSender(false) + if err != nil { + t.Errorf("failed to get sender: %s", err) + } + if !strings.EqualFold(sender, "toni.tester@example.com") { + t.Errorf("expected sender not returned. Want: %s, got: %s", "toni.tester@example.com", sender) + } + }) + t.Run("GetSender with from only (full address)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + sender, err := message.GetSender(true) + if err != nil { + t.Errorf("failed to get sender: %s", err) + } + if !strings.EqualFold(sender, "") { + t.Errorf("expected sender not returned. Want: %s, got: %s", "", sender) + } + }) + t.Run("GetSender with envelope from and from (no full address)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set envelope from address: %s", err) + } + if err := message.From("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + sender, err := message.GetSender(false) + if err != nil { + t.Errorf("failed to get sender: %s", err) + } + if !strings.EqualFold(sender, "toni.tester@example.com") { + t.Errorf("expected sender not returned. Want: %s, got: %s", "toni.tester@example.com", sender) + } + }) + t.Run("GetSender with envelope from and from (full address)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EnvelopeFrom("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set envelope from address: %s", err) + } + if err := message.From("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + sender, err := message.GetSender(true) + if err != nil { + t.Errorf("failed to get sender: %s", err) + } + if !strings.EqualFold(sender, "") { + t.Errorf("expected sender not returned. Want: %s, got: %s", "", sender) + } + }) + t.Run("GetSender with no envelope from or from", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + _, err := message.GetSender(false) + if err == nil { + t.Errorf("GetSender with no envelope from or from should return error") + } + if !errors.Is(err, ErrNoFromAddress) { + t.Errorf("GetSender with no envelope from or from should return error. Want: %s, got: %s", + ErrNoFromAddress, err) + } + }) +} + +func TestMsg_GetRecipients(t *testing.T) { + t.Run("GetRecipients with only to", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set to address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 1 { + t.Fatalf("expected 1 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + }) + t.Run("GetRecipients with only cc", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set cc address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 1 { + t.Fatalf("expected 1 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + }) + t.Run("GetRecipients with only bcc", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set bcc address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 1 { + t.Fatalf("expected 1 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + }) + t.Run("GetRecipients with to and cc", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set to address: %s", err) + } + if err := message.Cc("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set cc address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 2 { + t.Fatalf("expected 2 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + if !strings.EqualFold(rcpts[1], "tina.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "tina.tester@example.com", rcpts[1]) + } + }) + t.Run("GetRecipients with to and bcc", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set to address: %s", err) + } + if err := message.Bcc("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set bcc address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 2 { + t.Fatalf("expected 2 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + if !strings.EqualFold(rcpts[1], "tina.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "tina.tester@example.com", rcpts[1]) + } + }) + t.Run("GetRecipients with cc and bcc", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set cc address: %s", err) + } + if err := message.Bcc("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set bcc address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 2 { + t.Fatalf("expected 2 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + if !strings.EqualFold(rcpts[1], "tina.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "tina.tester@example.com", rcpts[1]) + } + }) + t.Run("GetRecipients with to, cc and bcc", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set to address: %s", err) + } + if err := message.Cc("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set cc address: %s", err) + } + if err := message.Bcc("tom.tester@example.com"); err != nil { + t.Fatalf("failed to set bcc address: %s", err) + } + rcpts, err := message.GetRecipients() + if err != nil { + t.Errorf("failed to get recipients: %s", err) + } + if len(rcpts) != 3 { + t.Fatalf("expected 3 recipient, got: %d", len(rcpts)) + } + if !strings.EqualFold(rcpts[0], "toni.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "toni.tester@example.com", rcpts[0]) + } + if !strings.EqualFold(rcpts[1], "tina.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "tina.tester@example.com", rcpts[1]) + } + if !strings.EqualFold(rcpts[2], "tom.tester@example.com") { + t.Errorf("expected recipient not returned. Want: %s, got: %s", + "tina.tester@example.com", rcpts[2]) + } + }) + t.Run("GetRecipients with no recipients", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + _, err := message.GetRecipients() + if err == nil { + t.Errorf("expected error, got nil") + } + if !errors.Is(err, ErrNoRcptAddresses) { + t.Errorf("expected ErrNoRcptAddresses, got: %s", err) + } + }) +} + +func TestMsg_GetAddrHeader(t *testing.T) { + t.Run("GetAddrHeader with valid address (from)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set header: %s", err) + } + addrheader := message.GetAddrHeader(HeaderFrom) + if len(addrheader) != 1 { + t.Errorf("expected 1 address, got: %d", len(addrheader)) + } + if addrheader[0] == nil { + t.Fatalf("expected address, got nil") + } + if addrheader[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[0].String()) + } + }) + t.Run("GetAddrHeader with valid address (to, cc, bcc)", func(t *testing.T) { + var fn func(...string) error + for _, tt := range addrHeaderTests { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + + switch tt.header { + case HeaderFrom: + continue + case HeaderTo: + fn = message.To + case HeaderCc: + fn = message.Cc + case HeaderBcc: + fn = message.Bcc + default: + t.Logf("header %s not supported", tt.header) + continue + } + t.Run(tt.name, func(t *testing.T) { + if err := fn("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set header: %s", err) + } + addrheader := message.GetAddrHeader(tt.header) + if len(addrheader) != 1 { + t.Errorf("expected 1 address, got: %d", len(addrheader)) + } + if addrheader[0] == nil { + t.Fatalf("expected address, got nil") + } + if addrheader[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[0].String()) + } + }) + } + }) + t.Run("GetAddrHeader with multiple valid address (to, cc, bcc)", func(t *testing.T) { + var fn func(...string) error + var addfn func(string) error + for _, tt := range addrHeaderTests { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + + switch tt.header { + case HeaderFrom: + continue + case HeaderTo: + fn = message.To + addfn = message.AddTo + case HeaderCc: + fn = message.Cc + addfn = message.AddCc + case HeaderBcc: + fn = message.Bcc + addfn = message.AddBcc + default: + t.Logf("header %s not supported", tt.header) + continue + } + t.Run(tt.name, func(t *testing.T) { + if err := fn("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set header: %s", err) + } + if err := addfn("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional header value: %s", err) + } + addrheader := message.GetAddrHeader(tt.header) + if len(addrheader) != 2 { + t.Errorf("expected 1 address, got: %d", len(addrheader)) + } + if addrheader[0] == nil { + t.Fatalf("expected address, got nil") + } + if addrheader[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[0].String()) + } + if addrheader[1] == nil { + t.Fatalf("expected address, got nil") + } + if addrheader[1].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[1].String()) + } + }) + } + }) + t.Run("GetAddrHeader with no addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + addrheader := message.GetAddrHeader(HeaderFrom) + if len(addrheader) != 0 { + t.Errorf("expected 0 address, got: %d", len(addrheader)) + } + }) + } + }) +} + +func TestMsg_GetAddrHeaderString(t *testing.T) { + t.Run("GetAddrHeaderString with valid address (from)", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set header: %s", err) + } + addrheader := message.GetAddrHeaderString(HeaderFrom) + if len(addrheader) != 1 { + t.Errorf("expected 1 address, got: %d", len(addrheader)) + } + if addrheader[0] == "" { + t.Fatalf("expected address, got empty string") + } + if addrheader[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[0]) + } + }) + t.Run("GetAddrHeaderString with valid address (to, cc, bcc)", func(t *testing.T) { + var fn func(...string) error + for _, tt := range addrHeaderTests { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + + switch tt.header { + case HeaderFrom: + continue + case HeaderTo: + fn = message.To + case HeaderCc: + fn = message.Cc + case HeaderBcc: + fn = message.Bcc + default: + t.Logf("header %s not supported", tt.header) + continue + } + t.Run(tt.name, func(t *testing.T) { + if err := fn("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set header: %s", err) + } + addrheader := message.GetAddrHeaderString(tt.header) + if len(addrheader) != 1 { + t.Errorf("expected 1 address, got: %d", len(addrheader)) + } + if addrheader[0] == "" { + t.Fatalf("expected address, got empty string") + } + if addrheader[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[0]) + } + }) + } + }) + t.Run("GetAddrHeaderString with multiple valid address (to, cc, bcc)", func(t *testing.T) { + var fn func(...string) error + var addfn func(string) error + for _, tt := range addrHeaderTests { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + + switch tt.header { + case HeaderFrom: + continue + case HeaderTo: + fn = message.To + addfn = message.AddTo + case HeaderCc: + fn = message.Cc + addfn = message.AddCc + case HeaderBcc: + fn = message.Bcc + addfn = message.AddBcc + default: + t.Logf("header %s not supported", tt.header) + continue + } + t.Run(tt.name, func(t *testing.T) { + if err := fn("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set header: %s", err) + } + if err := addfn("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set additional header value: %s", err) + } + addrheader := message.GetAddrHeaderString(tt.header) + if len(addrheader) != 2 { + t.Errorf("expected 1 address, got: %d", len(addrheader)) + } + if addrheader[0] == "" { + t.Fatalf("expected address, got empty string") + } + if addrheader[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[0]) + } + if addrheader[1] == "" { + t.Fatalf("expected address, got nil") + } + if addrheader[1] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addrheader[1]) + } + }) + } + }) + t.Run("GetAddrHeaderString with no addresses", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range addrHeaderTests { + t.Run(tt.name, func(t *testing.T) { + addrheader := message.GetAddrHeaderString(HeaderFrom) + if len(addrheader) != 0 { + t.Errorf("expected 0 address, got: %d", len(addrheader)) + } + }) + } + }) +} + +func TestMsg_GetFrom(t *testing.T) { + t.Run("GetFrom with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetFrom() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + }) + t.Run("GetFrom with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetFrom() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetFromString(t *testing.T) { + t.Run("GetFromString with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetFromString() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + }) + t.Run("GetFromString with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetFromString() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetTo(t *testing.T) { + t.Run("GetTo with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetTo() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + }) + t.Run("GetTo with multiple address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetTo() + if len(addresses) != 2 { + t.Fatalf("expected 2 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + if addresses[1] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[1].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[1].String()) + } + }) + t.Run("GetTo with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetTo() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetToString(t *testing.T) { + t.Run("GetToString with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetToString() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("GetToString: expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + }) + t.Run("GetToString with multiple address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.To("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetToString() + if len(addresses) != 2 { + t.Fatalf("expected 2 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + if addresses[1] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[1] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[1]) + } + }) + t.Run("GetToString with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetToString() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetCc(t *testing.T) { + t.Run("GetCc with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetCc() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + }) + t.Run("GetCc with multiple address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetCc() + if len(addresses) != 2 { + t.Fatalf("expected 2 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + if addresses[1] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[1].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[1].String()) + } + }) + t.Run("GetCc with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetCc() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetCcString(t *testing.T) { + t.Run("GetCcString with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetCcString() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + }) + t.Run("GetCcString with multiple address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Cc("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetCcString() + if len(addresses) != 2 { + t.Fatalf("expected 2 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + if addresses[1] == "" { + t.Fatalf("GetCcString: expected address, got nil") + } + if addresses[1] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[1]) + } + }) + t.Run("GetCcString with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetCcString() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetBcc(t *testing.T) { + t.Run("GetBcc with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetBcc() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + }) + t.Run("GetBcc with multiple address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetBcc() + if len(addresses) != 2 { + t.Fatalf("expected 2 address, got: %d", len(addresses)) + } + if addresses[0] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[0].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0].String()) + } + if addresses[1] == nil { + t.Fatalf("expected address, got nil") + } + if addresses[1].String() != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[1].String()) + } + }) + t.Run("GetBcc with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetBcc() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetBccString(t *testing.T) { + t.Run("GetBccString with address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetBccString() + if len(addresses) != 1 { + t.Fatalf("expected 1 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + }) + t.Run("GetBccString with multiple address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.Bcc("toni.tester@example.com", "tina.tester@example.com"); err != nil { + t.Fatalf("failed to set from address: %s", err) + } + addresses := message.GetBccString() + if len(addresses) != 2 { + t.Fatalf("expected 2 address, got: %d", len(addresses)) + } + if addresses[0] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[0] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[0]) + } + if addresses[1] == "" { + t.Fatalf("expected address, got nil") + } + if addresses[1] != "" { + t.Errorf("expected address not returned. Want: %s, got: %s", + "", addresses[1]) + } + }) + t.Run("GetBccString with no address", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + addresses := message.GetBccString() + if len(addresses) != 0 { + t.Errorf("expected 0 address, got: %d", len(addresses)) + } + }) +} + +func TestMsg_GetGenHeader(t *testing.T) { + t.Run("GetGenHeader with single value", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message.SetGenHeader(tt.header, "test") + values := message.GetGenHeader(tt.header) + if len(values) != 1 { + t.Errorf("expected 1 value, got: %d", len(values)) + } + if values[0] != "test" { + t.Errorf("expected value not returned. Want: %s, got: %s", + "test", values[0]) + } + }) + } + }) + t.Run("GetGenHeader with multiple values", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message.SetGenHeader(tt.header, "test", "foobar") + values := message.GetGenHeader(tt.header) + if len(values) != 2 { + t.Errorf("expected 1 value, got: %d", len(values)) + } + if values[0] != "test" { + t.Errorf("expected value not returned. Want: %s, got: %s", + "test", values[0]) + } + if values[1] != "foobar" { + t.Errorf("expected value not returned. Want: %s, got: %s", + "foobar", values[1]) + } + }) + } + }) + t.Run("GetGenHeader with nil", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + for _, tt := range genHeaderTests { + t.Run(tt.name, func(t *testing.T) { + message.SetGenHeader(tt.header) + values := message.GetGenHeader(tt.header) + if len(values) != 0 { + t.Errorf("expected 1 value, got: %d", len(values)) + } + }) + } + }) +} + +func TestMsg_GetParts(t *testing.T) { + t.Run("GetParts with single part", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyString(TypeTextPlain, "this is a test body") + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatalf("expected part, got nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be TypeTextPlain, got: %s", parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "this is a test body") { + t.Errorf("expected message body to be %s, got: %s", "this is a test body", + messageBuf.String()) + } + }) + t.Run("GetParts with multiple parts", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyString(TypeTextPlain, "this is a test body") + message.AddAlternativeString(TypeTextHTML, "

This is HTML

") + parts := message.GetParts() + if len(parts) != 2 { + t.Fatalf("expected 2 parts, got: %d", len(parts)) + } + if parts[0] == nil || parts[1] == nil { + t.Fatalf("expected parts, got nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be TypeTextPlain, got: %s", parts[0].contentType) + } + if parts[1].contentType != TypeTextHTML { + t.Errorf("expected contentType to be TypeTextHTML, got: %s", parts[1].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "this is a test body") { + t.Errorf("expected message body to be %s, got: %s", "this is a test body", + messageBuf.String()) + } + messageBuf.Reset() + _, err = parts[1].writeFunc(messageBuf) + if err != nil { + t.Errorf("GetParts: writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "

This is HTML

") { + t.Errorf("expected message body to be %s, got: %s", "

This is HTML

", + messageBuf.String()) + } + }) + t.Run("GetParts with no parts", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + parts := message.GetParts() + if len(parts) != 0 { + t.Fatalf("expected no parts, got: %d", len(parts)) + } + }) +} + +func TestMsg_GetAttachments(t *testing.T) { + t.Run("GetAttachments with single attachment", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile("testdata/attachment.txt") + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("expected 1 attachment, got: %d", len(attachments)) + } + if attachments[0] == nil { + t.Fatalf("expected attachment, got nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", + attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("GetAttachments with multiple attachments", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile("testdata/attachment.txt") + message.AttachFile("testdata/attachment.txt", WithFileName("attachment2.txt")) + attachments := message.GetAttachments() + if len(attachments) != 2 { + t.Fatalf("expected 2 attachment, got: %d", len(attachments)) + } + if attachments[0] == nil || attachments[1] == nil { + t.Fatalf("expected attachment, got nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", + attachments[0].Name) + } + if attachments[1].Name != "attachment2.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment2.txt", + attachments[1].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + messageBuf.Reset() + _, err = attachments[1].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got = strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("GetAttachments with no attachment", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + attachments := message.GetAttachments() + if len(attachments) != 0 { + t.Fatalf("expected 1 attachment, got: %d", len(attachments)) + } + }) +} + +func TestMsg_GetBoundary(t *testing.T) { + t.Run("GetBoundary", func(t *testing.T) { + message := NewMsg(WithBoundary("test")) + if message == nil { + t.Fatal("message is nil") + } + if message.GetBoundary() != "test" { + t.Errorf("expected %s, got: %s", "test", message.GetBoundary()) + } + }) + t.Run("GetBoundary with no boundary", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if message.GetBoundary() != "" { + t.Errorf("expected empty, got: %s", message.GetBoundary()) + } + }) +} + +func TestMsg_SetAttachments(t *testing.T) { + t.Run("SetAttachments with single file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "attachment.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test attachment")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetAttachments([]*File{file}) + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("expected 1 attachment, got: %d", len(attachments)) + } + if attachments[0] == nil { + t.Fatalf("expected attachment, got nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", + attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("SetAttachments with multiple files", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file1 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "attachment.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test attachment")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + file2 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file no. 2", + Name: "attachment2.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is also a test attachment")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetAttachments([]*File{file1, file2}) + attachments := message.GetAttachments() + if len(attachments) != 2 { + t.Fatalf("expected 2 attachment, got: %d", len(attachments)) + } + if attachments[0] == nil || attachments[1] == nil { + t.Fatalf("expected attachment, got nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", + attachments[0].Name) + } + if attachments[1].Name != "attachment2.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment2.txt", + attachments[1].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(messageBuf.String(), "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + messageBuf.Reset() + _, err = attachments[1].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got = strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is also a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is also a test attachment", got) + } + }) + t.Run("SetAttachments with no file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetAttachments(nil) + attachments := message.GetAttachments() + if len(attachments) != 0 { + t.Fatalf("expected 0 attachment, got: %d", len(attachments)) + } + }) +} + +func TestMsg_SetAttachements(t *testing.T) { + message := NewMsg() + //goland:noinspection GoDeprecation + message.SetAttachements(nil) + t.Log("SetAttachements is deprecated and fully tested by SetAttachments already") +} + +func TestMsg_UnsetAllAttachments(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file1 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "attachment.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test attachment")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + file2 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file no. 2", + Name: "attachment2.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is also a test attachment")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetAttachments([]*File{file1, file2}) + message.UnsetAllAttachments() + if message.attachments != nil { + t.Errorf("expected attachments to be nil, got: %v", message.attachments) + } + attachments := message.GetAttachments() + if len(attachments) != 0 { + t.Fatalf("expected 0 attachment, got: %d", len(attachments)) + } +} + +func TestMsg_GetEmbeds(t *testing.T) { + t.Run("GetEmbeds with single embed", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.EmbedFile("testdata/embed.txt") + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("expected 1 embed, got: %d", len(embeds)) + } + if embeds[0] == nil { + t.Fatalf("expected embed, got nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", + embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("Writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("GetEmbeds with multiple embeds", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.EmbedFile("testdata/embed.txt") + message.EmbedFile("testdata/embed.txt", WithFileName("embed2.txt")) + embeds := message.GetEmbeds() + if len(embeds) != 2 { + t.Fatalf("expected 2 embed, got: %d", len(embeds)) + } + if embeds[0] == nil || embeds[1] == nil { + t.Fatalf("expected embed, got nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", + embeds[0].Name) + } + if embeds[1].Name != "embed2.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed2.txt", + embeds[1].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("Writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + messageBuf.Reset() + _, err = embeds[1].Writer(messageBuf) + if err != nil { + t.Errorf("Writer func failed: %s", err) + } + got = strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("GetEmbeds with no embeds", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + embeds := message.GetEmbeds() + if len(embeds) != 0 { + t.Fatalf("expected 1 embeds, got: %d", len(embeds)) + } + }) +} + +func TestMsg_SetEmbeds(t *testing.T) { + t.Run("SetEmbeds with single file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "embed.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetEmbeds([]*File{file}) + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("expected 1 embed, got: %d", len(embeds)) + } + if embeds[0] == nil { + t.Fatalf("expected embed, got nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", + embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("Writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("SetEmbeds with multiple files", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file1 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "embed.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + file2 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file no. 2", + Name: "embed2.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is also a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetEmbeds([]*File{file1, file2}) + embeds := message.GetEmbeds() + if len(embeds) != 2 { + t.Fatalf("expected 2 embed, got: %d", len(embeds)) + } + if embeds[0] == nil || embeds[1] == nil { + t.Fatalf("expected embed, got nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", + embeds[0].Name) + } + if embeds[1].Name != "embed2.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed2.txt", + embeds[1].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("Writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + messageBuf.Reset() + _, err = embeds[1].Writer(messageBuf) + if err != nil { + t.Errorf("Writer func failed: %s", err) + } + got = strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is also a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is also a test embed", got) + } + }) + t.Run("SetEmbeds with no file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetEmbeds(nil) + embeds := message.GetEmbeds() + if len(embeds) != 0 { + t.Fatalf("expected 0 embed, got: %d", len(embeds)) + } + }) +} + +func TestMsg_UnsetAllEmbeds(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file1 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "embed.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + file2 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file no. 2", + Name: "embed2.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is also a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetEmbeds([]*File{file1, file2}) + message.UnsetAllEmbeds() + if message.embeds != nil { + t.Errorf("expected embeds to be nil, got: %v", message.embeds) + } + embeds := message.GetEmbeds() + if len(embeds) != 0 { + t.Fatalf("expected 0 embed, got: %d", len(embeds)) + } +} + +func TestMsg_UnsetAllParts(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file1 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file", + Name: "embed.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + file2 := &File{ + ContentType: TypeTextPlain, + Desc: "Test file no. 2", + Name: "embed2.txt", + Writer: func(w io.Writer) (int64, error) { + buf := bytes.NewBuffer([]byte("This is also a test embed")) + n, err := w.Write(buf.Bytes()) + return int64(n), err + }, + } + message.SetAttachments([]*File{file1}) + message.SetEmbeds([]*File{file2}) + message.UnsetAllParts() + if message.embeds != nil || message.attachments != nil { + t.Error("expected attachments/embeds to be nil, got: value") + } + embeds := message.GetEmbeds() + if len(embeds) != 0 { + t.Fatalf("expected 0 embed, got: %d", len(embeds)) + } + attachments := message.GetAttachments() + if len(attachments) != 0 { + t.Fatalf("expected 0 attachments, got: %d", len(attachments)) + } +} + +func TestMsg_SetBodyString(t *testing.T) { + t.Run("SetBodyString on all types", func(t *testing.T) { + for _, tt := range contentTypeTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyString(tt.ctype, "test") + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != tt.ctype { + t.Errorf("expected contentType to be %s, got: %s", tt.ctype, + parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) +} + +func TestMsg_SetBodyWriter(t *testing.T) { + writerFunc := func(w io.Writer) (int64, error) { + buffer := bytes.NewBufferString("test") + n, err := w.Write(buffer.Bytes()) + return int64(n), err + } + t.Run("SetBodyWriter on all types", func(t *testing.T) { + for _, tt := range contentTypeTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyWriter(tt.ctype, writerFunc) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != tt.ctype { + t.Errorf("expected contentType to be %s, got: %s", tt.ctype, + parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) + t.Run("SetBodyWriter WithPartCharset", func(t *testing.T) { + for _, tt := range charsetTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyWriter(TypeTextPlain, writerFunc, WithPartCharset(tt.value)) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[0].charset != tt.value { + t.Errorf("expected charset to be %s, got: %s", tt.value, parts[0].charset) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) + t.Run("SetBodyWriter WithPartEncoding", func(t *testing.T) { + for _, tt := range encodingTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyWriter(TypeTextPlain, writerFunc, WithPartEncoding(tt.value)) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[0].encoding != tt.value { + t.Errorf("expected encoding to be %s, got: %s", tt.value, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) + t.Run("SetBodyWriter WithPartContentDescription", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyWriter(TypeTextPlain, writerFunc, WithPartContentDescription("description")) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[0].description != "description" { + t.Errorf("expected description to be %s, got: %s", "description", parts[0].description) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + t.Run("SetBodyWriter with nil option", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyWriter(TypeTextPlain, writerFunc, nil) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) +} + +func TestMsg_SetBodyHTMLTemplate(t *testing.T) { + tplString := `

{{.teststring}}

` + invalidTplString := `

{{call $.invalid .teststring}}

` + data := map[string]interface{}{"teststring": "this is a test"} + htmlTpl, err := ht.New("htmltpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse HTML template: %s", err) + } + invalidTpl, err := ht.New("htmltpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid HTML template: %s", err) + } + t.Run("SetBodyHTMLTemplate default", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.SetBodyHTMLTemplate(htmlTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextHTML { + t.Errorf("expected contentType to be %s, got: %s", TypeTextHTML, parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "

this is a test

") { + t.Errorf("expected message body to be %s, got: %s", "

this is a test

", messageBuf.String()) + } + }) + t.Run("SetBodyHTMLTemplate with nil tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.SetBodyHTMLTemplate(nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.EqualFold(err.Error(), errTplPointerNil) { + t.Errorf("expected error to be %s, got: %s", errTplPointerNil, err.Error()) + } + }) + t.Run("SetBodyHTMLTemplate with invalid tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.SetBodyHTMLTemplate(invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to execute template: template: htmltpl:1:5: executing "htmltpl" at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) +} + +func TestMsg_SetBodyTextTemplate(t *testing.T) { + tplString := `Teststring: {{.teststring}}` + invalidTplString := `Teststring: {{call $.invalid .teststring}}` + data := map[string]interface{}{"teststring": "this is a test"} + textTpl, err := ttpl.New("texttpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse Text template: %s", err) + } + invalidTpl, err := ttpl.New("texttpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid Text template: %s", err) + } + t.Run("SetBodyTextTemplate default", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.SetBodyTextTemplate(textTpl, data); err != nil { + t.Fatalf("failed to set body text template: %s", err) + } + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "Teststring: this is a test") { + t.Errorf("expected message body to be %s, got: %s", "Teststring: this is a test", messageBuf.String()) + } + }) + t.Run("SetBodyTextTemplate with nil tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.SetBodyTextTemplate(nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.EqualFold(err.Error(), errTplPointerNil) { + t.Errorf("expected error to be %s, got: %s", errTplPointerNil, err.Error()) + } + }) + t.Run("SetBodyTextTemplate with invalid tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.SetBodyTextTemplate(invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to execute template: template: texttpl:1:14: executing "texttpl" at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) +} + +func TestMsg_AddAlternativeString(t *testing.T) { + t.Run("AddAlternativeString on all types", func(t *testing.T) { + for _, tt := range contentTypeTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AddAlternativeString(tt.ctype, "test") + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != tt.ctype { + t.Errorf("expected contentType to be %s, got: %s", tt.ctype, + parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) +} + +func TestMsg_AddAlternativeWriter(t *testing.T) { + writerFunc := func(w io.Writer) (int64, error) { + buffer := bytes.NewBufferString("test") + n, err := w.Write(buffer.Bytes()) + return int64(n), err + } + t.Run("AddAlternativeWriter on all types", func(t *testing.T) { + for _, tt := range contentTypeTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AddAlternativeWriter(tt.ctype, writerFunc) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != tt.ctype { + t.Errorf("expected contentType to be %s, got: %s", tt.ctype, + parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) + t.Run("AddAlternativeWriter WithPartCharset", func(t *testing.T) { + for _, tt := range charsetTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AddAlternativeWriter(TypeTextPlain, writerFunc, WithPartCharset(tt.value)) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[0].charset != tt.value { + t.Errorf("expected charset to be %s, got: %s", tt.value, parts[0].charset) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) + t.Run("AddAlternativeWriter WithPartEncoding", func(t *testing.T) { + for _, tt := range encodingTests { + t.Run(tt.name, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AddAlternativeWriter(TypeTextPlain, writerFunc, WithPartEncoding(tt.value)) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[0].encoding != tt.value { + t.Errorf("expected encoding to be %s, got: %s", tt.value, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + } + }) + t.Run("AddAlternativeWriter WithPartContentDescription", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AddAlternativeWriter(TypeTextPlain, writerFunc, WithPartContentDescription("description")) + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[0].description != "description" { + t.Errorf("expected description to be %s, got: %s", "description", parts[0].description) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "test") { + t.Errorf("expected message body to be %s, got: %s", "test", messageBuf.String()) + } + }) + t.Run("AddAlternativeWriter with body string set", func(t *testing.T) { + writerFunc = func(w io.Writer) (int64, error) { + buffer := bytes.NewBufferString("

alternative body

") + n, err := w.Write(buffer.Bytes()) + return int64(n), err + } + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyString(TypeTextPlain, "body string") + message.AddAlternativeWriter(TypeTextHTML, writerFunc) + parts := message.GetParts() + if len(parts) != 2 { + t.Fatalf("expected 2 part, got: %d", len(parts)) + } + if parts[0] == nil || parts[1] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, + parts[0].contentType) + } + if parts[1].contentType != TypeTextHTML { + t.Errorf("expected alternative contentType to be %s, got: %s", TypeTextHTML, + parts[1].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err := parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "body string") { + t.Errorf("expected message body to be %s, got: %s", "body string", messageBuf.String()) + } + messageBuf.Reset() + _, err = parts[1].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "

alternative body

") { + t.Errorf("expected alternative message body to be %s, got: %s", "

alternative body

", messageBuf.String()) + } + }) +} + +func TestMsg_AddAlternativeHTMLTemplate(t *testing.T) { + tplString := `

{{.teststring}}

` + invalidTplString := `

{{call $.invalid .teststring}}

` + data := map[string]interface{}{"teststring": "this is a test"} + htmlTpl, err := ht.New("htmltpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse HTML template: %s", err) + } + invalidTpl, err := ht.New("htmltpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid HTML template: %s", err) + } + t.Run("AddAlternativeHTMLTemplate default", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.AddAlternativeHTMLTemplate(htmlTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextHTML { + t.Errorf("expected contentType to be %s, got: %s", TypeTextHTML, parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "

this is a test

") { + t.Errorf("expected message body to be %s, got: %s", "

this is a test

", messageBuf.String()) + } + }) + t.Run("AddAlternativeHTMLTemplate with body string", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyString(TypeTextPlain, "body string") + if err = message.AddAlternativeHTMLTemplate(htmlTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + parts := message.GetParts() + if len(parts) != 2 { + t.Fatalf("expected 2 part, got: %d", len(parts)) + } + if parts[0] == nil || parts[1] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[1].contentType != TypeTextHTML { + t.Errorf("expected contentType to be %s, got: %s", TypeTextHTML, parts[1].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "body string") { + t.Errorf("expected message body to be %s, got: %s", "body string", messageBuf.String()) + } + messageBuf.Reset() + _, err = parts[1].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "

this is a test

") { + t.Errorf("expected message body to be %s, got: %s", "

this is a test

", messageBuf.String()) + } + }) + t.Run("AddAlternativeHTMLTemplate with nil tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AddAlternativeHTMLTemplate(nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.EqualFold(err.Error(), errTplPointerNil) { + t.Errorf("expected error to be %s, got: %s", errTplPointerNil, err.Error()) + } + }) + t.Run("AddAlternativeHTMLTemplate with invalid tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AddAlternativeHTMLTemplate(invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to execute template: template: htmltpl:1:5: executing "htmltpl" at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) +} + +func TestMsg_AddAlternativeTextTemplate(t *testing.T) { + tplString := `Teststring: {{.teststring}}` + invalidTplString := `Teststring: {{call $.invalid .teststring}}` + data := map[string]interface{}{"teststring": "this is a test"} + textTpl, err := ttpl.New("texttpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse Text template: %s", err) + } + invalidTpl, err := ttpl.New("texttpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid Text template: %s", err) + } + t.Run("AddAlternativeTextTemplate default", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.AddAlternativeTextTemplate(textTpl, data); err != nil { + t.Fatalf("failed to set body text template: %s", err) + } + parts := message.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 part, got: %d", len(parts)) + } + if parts[0] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "Teststring: this is a test") { + t.Errorf("expected message body to be %s, got: %s", "Teststring: this is a test", messageBuf.String()) + } + }) + t.Run("AddAlternativeTextTemplate with body string", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.SetBodyString(TypeTextPlain, "body string") + if err = message.AddAlternativeTextTemplate(textTpl, data); err != nil { + t.Fatalf("failed to set body text template: %s", err) + } + parts := message.GetParts() + if len(parts) != 2 { + t.Fatalf("expected 2 part, got: %d", len(parts)) + } + if parts[0] == nil || parts[1] == nil { + t.Fatal("expected part to be not nil") + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[1].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[1].contentType) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "body string") { + t.Errorf("expected message body to be %s, got: %s", "body string", messageBuf.String()) + } + messageBuf.Reset() + _, err = parts[1].writeFunc(messageBuf) + if err != nil { + t.Errorf("writeFunc failed: %s", err) + } + if !strings.EqualFold(messageBuf.String(), "Teststring: this is a test") { + t.Errorf("expected message body to be %s, got: %s", "Teststring: this is a test", messageBuf.String()) + } + }) + t.Run("AddAlternativeTextTemplate with nil tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AddAlternativeTextTemplate(nil, nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.EqualFold(err.Error(), errTplPointerNil) { + t.Errorf("expected error to be %s, got: %s", errTplPointerNil, err.Error()) + } + }) + t.Run("AddAlternativeTextTemplate with invalid tpl", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AddAlternativeTextTemplate(invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to execute template: template: texttpl:1:14: executing "texttpl" at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) +} + +func TestMsg_AttachFile(t *testing.T) { + t.Run("AttachFile with file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile("testdata/attachment.txt") + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("AttachFile with non-existant file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile("testdata/non-existant-file.txt") + attachments := message.GetAttachments() + if len(attachments) != 0 { + t.Fatalf("failed to retrieve attachments list") + } + }) + t.Run("AttachFile with options", func(t *testing.T) { + t.Log("all options have already been tested in file_test.go") + }) + t.Run("AttachFile with normal file and nil option", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile("testdata/attachment.txt", nil) + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("AttachFile with fileFromFS fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile("testdata/attachment.txt") + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to get attachments, expected 1, got: %d", len(attachments)) + } + _, err := attachments[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) +} + +func TestMsg_AttachReader(t *testing.T) { + t.Run("AttachReader with file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/attachment.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + if err = message.AttachReader("attachment.txt", file); err != nil { + t.Fatalf("failed to attach reader: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("AttachReader with fileFromReader fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/attachment.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + if err = message.AttachReader("attachment.txt", file); err != nil { + t.Fatalf("failed to attach reader: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to get attachments, expected 1, got: %d", len(attachments)) + } + _, err = attachments[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) + // Tests the Msg.AttachReader methods with consecutive calls to Msg.WriteTo to make sure + // the attachments are not lost. + // https://github.com/wneessen/go-mail/issues/110 + t.Run("AttachReader with consecutive writes", func(t *testing.T) { + teststring := "This is a test string" + message := testMessage(t) + if err := message.AttachReader("attachment.txt", bytes.NewBufferString(teststring)); err != nil { + t.Fatalf("failed to attach teststring buffer: %s", err) + } + messageBuffer := bytes.NewBuffer(nil) + altBuffer := bytes.NewBuffer(nil) + // First write + n1, err := message.WriteTo(messageBuffer) + if err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + // Second write + n2, err := message.WriteTo(altBuffer) + if err != nil { + t.Fatalf("failed to write message to alternative buffer: %s", err) + } + if n1 != n2 { + t.Errorf("number of written bytes between consecutive writes differ, got: %d and %d", n1, n2) + } + if !strings.Contains(messageBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", messageBuffer.String()) + } + if !strings.Contains(altBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in alternative message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", altBuffer.String()) + } + }) +} + +func TestMsg_AttachReadSeeker(t *testing.T) { + t.Run("AttachReadSeeker with file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/attachment.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + message.AttachReadSeeker("attachment.txt", file) + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("AttachReadSeeker with fileFromReadSeeker fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/attachment.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + message.AttachReadSeeker("attachment.txt", file) + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to get attachments, expected 1, got: %d", len(attachments)) + } + _, err = attachments[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) + // Tests the Msg.AttachReadSeeker methods with consecutive calls to Msg.WriteTo to make sure + // the attachments are not lost. + // https://github.com/wneessen/go-mail/issues/110 + t.Run("AttachReadSeeker with consecutive writes", func(t *testing.T) { + teststring := []byte("This is a test string") + message := testMessage(t) + message.AttachReadSeeker("attachment.txt", bytes.NewReader(teststring)) + messageBuffer := bytes.NewBuffer(nil) + altBuffer := bytes.NewBuffer(nil) + // First write + n1, err := message.WriteTo(messageBuffer) + if err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + // Second write + n2, err := message.WriteTo(altBuffer) + if err != nil { + t.Fatalf("failed to write message to alternative buffer: %s", err) + } + if n1 != n2 { + t.Errorf("number of written bytes between consecutive writes differ, got: %d and %d", n1, n2) + } + if !strings.Contains(messageBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", messageBuffer.String()) + } + if !strings.Contains(altBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in alternative message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", altBuffer.String()) + } + }) +} + +func TestMsg_AttachHTMLTemplate(t *testing.T) { + tplString := `

{{.teststring}}

` + invalidTplString := `

{{call $.invalid .teststring}}

` + data := map[string]interface{}{"teststring": "this is a test"} + htmlTpl, err := ht.New("htmltpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse HTML template: %s", err) + } + invalidTpl, err := ht.New("htmltpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid HTML template: %s", err) + } + t.Run("AttachHTMLTemplate with valid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.AttachHTMLTemplate("attachment.html", htmlTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.html" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.html", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "

this is a test

") { + t.Errorf("expected message body to be %s, got: %s", "

this is a test

", got) + } + }) + t.Run("AttachHTMLTemplate with invalid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AttachHTMLTemplate("attachment.html", invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to attach template: failed to execute template: template: htmltpl:1:5: executing "htmltpl" ` + + `at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) + t.Run("AttachHTMLTemplate with nil template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AttachHTMLTemplate("attachment.html", nil, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectedErr := `failed to attach template: ` + errTplPointerNil + if !strings.EqualFold(err.Error(), expectedErr) { + t.Errorf("expected error to be %s, got: %s", expectedErr, err.Error()) + } + }) +} + +func TestMsg_AttachTextTemplate(t *testing.T) { + tplString := `Teststring: {{.teststring}}` + invalidTplString := `Teststring: {{call $.invalid .teststring}}` + data := map[string]interface{}{"teststring": "this is a test"} + textTpl, err := ttpl.New("texttpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse Text template: %s", err) + } + invalidTpl, err := ttpl.New("texttpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid Text template: %s", err) + } + t.Run("AttachTextTemplate with valid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.AttachTextTemplate("attachment.txt", textTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "Teststring: this is a test") { + t.Errorf("expected message body to be %s, got: %s", "Teststring: this is a test", got) + } + }) + t.Run("AttachTextTemplate with invalid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AttachTextTemplate("attachment.txt", invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to attach template: failed to execute template: template: texttpl:1:14: executing "texttpl" ` + + `at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) + t.Run("AttachTextTemplate with nil template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.AttachTextTemplate("attachment.html", nil, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectedErr := `failed to attach template: ` + errTplPointerNil + if !strings.EqualFold(err.Error(), expectedErr) { + t.Errorf("expected error to be %s, got: %s", expectedErr, err.Error()) + } + }) +} + +func TestMsg_AttachFromEmbedFS(t *testing.T) { + t.Run("AttachFromEmbedFS successful", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.AttachFromEmbedFS("testdata/attachment.txt", &efs, + WithFileName("attachment.txt")); err != nil { + t.Fatalf("failed to attach from embed FS: %s", err) + } + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to retrieve attachments list") + } + if attachments[0] == nil { + t.Fatal("expected attachment to be not nil") + } + if attachments[0].Name != "attachment.txt" { + t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := attachments[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test attachment") { + t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got) + } + }) + t.Run("AttachFromEmbedFS with invalid path", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.AttachFromEmbedFS("testdata/invalid.txt", &efs, WithFileName("attachment.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + t.Run("AttachFromEmbedFS with nil embed FS", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.AttachFromEmbedFS("testdata/invalid.txt", nil, WithFileName("attachment.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestMsg_EmbedFile(t *testing.T) { + t.Run("EmbedFile with file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.EmbedFile("testdata/embed.txt") + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("EmbedFile with non-existant file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.EmbedFile("testdata/non-existant-file.txt") + embeds := message.GetEmbeds() + if len(embeds) != 0 { + t.Fatalf("failed to retrieve attachments list") + } + }) + t.Run("EmbedFile with fileFromFS fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.EmbedFile("testdata/embed.txt") + emebeds := message.GetEmbeds() + if len(emebeds) != 1 { + t.Fatalf("failed to get emebeds, expected 1, got: %d", len(emebeds)) + } + _, err := emebeds[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) + t.Run("EmbedFile with options", func(t *testing.T) { + t.Log("all options have already been tested in file_test.go") + }) +} + +func TestMsg_EmbedReader(t *testing.T) { + t.Run("EmbedReader with file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/embed.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + if err = message.EmbedReader("embed.txt", file); err != nil { + t.Fatalf("failed to embed reader: %s", err) + } + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("EmbedReader with fileFromReader fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/embed.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + if err = message.EmbedReader("embed.txt", file); err != nil { + t.Fatalf("failed to embed reader: %s", err) + } + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to get embeds, expected 1, got: %d", len(embeds)) + } + _, err = embeds[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) + t.Run("EmbedReader with fileFromReader on closed reader", func(t *testing.T) { + tempfile, err := os.CreateTemp("", "embedfile-close-reader.*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + if err = tempfile.Close(); err != nil { + t.Fatalf("failed to close temp file: %s", err) + } + t.Cleanup(func() { + if err := os.Remove(tempfile.Name()); err != nil { + t.Errorf("failed to remove temp file: %s", err) + } + }) + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.EmbedReader("embed.txt", tempfile); err == nil { + t.Fatalf("expected error, got nil") + } + }) + // Tests the Msg.EmbedReader methods with consecutive calls to Msg.WriteTo to make sure + // the attachments are not lost. + // https://github.com/wneessen/go-mail/issues/110 + t.Run("EmbedReader with consecutive writes", func(t *testing.T) { + teststring := "This is a test string" + message := testMessage(t) + if err := message.EmbedReader("embed.txt", bytes.NewBufferString(teststring)); err != nil { + t.Fatalf("failed to embed teststring buffer: %s", err) + } + messageBuffer := bytes.NewBuffer(nil) + altBuffer := bytes.NewBuffer(nil) + // First write + n1, err := message.WriteTo(messageBuffer) + if err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + // Second write + n2, err := message.WriteTo(altBuffer) + if err != nil { + t.Fatalf("failed to write message to alternative buffer: %s", err) + } + if n1 != n2 { + t.Errorf("number of written bytes between consecutive writes differ, got: %d and %d", n1, n2) + } + if !strings.Contains(messageBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", messageBuffer.String()) + } + if !strings.Contains(altBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in alternative message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", altBuffer.String()) + } + }) +} + +func TestMsg_EmbedReadSeeker(t *testing.T) { + t.Run("EmbedReadSeeker with file", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/embed.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + message.EmbedReadSeeker("embed.txt", file) + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("EmbedReadSeeker with fileFromReadSeeker fails on copy", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + file, err := os.Open("testdata/embed.txt") + if err != nil { + t.Fatalf("failed to open file: %s", err) + } + t.Cleanup(func() { + if err := file.Close(); err != nil { + t.Errorf("failed to close file: %s", err) + } + }) + message.EmbedReadSeeker("embed.txt", file) + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to get embeds, expected 1, got: %d", len(embeds)) + } + _, err = embeds[0].Writer(failReadWriteSeekCloser{}) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + }) + // Tests the Msg.EmbedReadSeeker methods with consecutive calls to Msg.WriteTo to make sure + // the attachments are not lost. + // https://github.com/wneessen/go-mail/issues/110 + t.Run("EmbedReadSeeker with consecutive writes", func(t *testing.T) { + teststring := []byte("This is a test string") + message := testMessage(t) + message.EmbedReadSeeker("embed.txt", bytes.NewReader(teststring)) + messageBuffer := bytes.NewBuffer(nil) + altBuffer := bytes.NewBuffer(nil) + // First write + n1, err := message.WriteTo(messageBuffer) + if err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + // Second write + n2, err := message.WriteTo(altBuffer) + if err != nil { + t.Fatalf("failed to write message to alternative buffer: %s", err) + } + if n1 != n2 { + t.Errorf("number of written bytes between consecutive writes differ, got: %d and %d", n1, n2) + } + if !strings.Contains(messageBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", messageBuffer.String()) + } + if !strings.Contains(altBuffer.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { + t.Errorf("expected string not found in alternative message buffer, want: %s, got: %s", + "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n", altBuffer.String()) + } + }) +} + +func TestMsg_EmbedHTMLTemplate(t *testing.T) { + tplString := `

{{.teststring}}

` + invalidTplString := `

{{call $.invalid .teststring}}

` + data := map[string]interface{}{"teststring": "this is a test"} + htmlTpl, err := ht.New("htmltpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse HTML template: %s", err) + } + invalidTpl, err := ht.New("htmltpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid HTML template: %s", err) + } + t.Run("EmbedHTMLTemplate with valid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.EmbedHTMLTemplate("embed.html", htmlTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.html" { + t.Errorf("expected embed name to be %s, got: %s", "embed.html", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "

this is a test

") { + t.Errorf("expected message body to be %s, got: %s", "

this is a test

", got) + } + }) + t.Run("EmbedHTMLTemplate with invalid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.EmbedHTMLTemplate("embed.html", invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to embed template: failed to execute template: template: htmltpl:1:5: executing "htmltpl" ` + + `at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) + t.Run("EmbedHTMLTemplate with nil template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.EmbedHTMLTemplate("embed.html", nil, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectedErr := `failed to embed template: ` + errTplPointerNil + if !strings.EqualFold(err.Error(), expectedErr) { + t.Errorf("expected error to be %s, got: %s", expectedErr, err.Error()) + } + }) +} + +func TestMsg_EmbedTextTemplate(t *testing.T) { + tplString := `Teststring: {{.teststring}}` + invalidTplString := `Teststring: {{call $.invalid .teststring}}` + data := map[string]interface{}{"teststring": "this is a test"} + textTpl, err := ttpl.New("texttpl").Parse(tplString) + if err != nil { + t.Fatalf("failed to parse Text template: %s", err) + } + invalidTpl, err := ttpl.New("texttpl").Parse(invalidTplString) + if err != nil { + t.Fatalf("failed to parse invalid Text template: %s", err) + } + t.Run("EmbedTextTemplate with valid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err = message.EmbedTextTemplate("embed.txt", textTpl, data); err != nil { + t.Fatalf("failed to set body HTML template: %s", err) + } + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err = embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "Teststring: this is a test") { + t.Errorf("expected message body to be %s, got: %s", "Teststring: this is a test", got) + } + }) + t.Run("EmbedTextTemplate with invalid template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.EmbedTextTemplate("embed.txt", invalidTpl, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectErr := `failed to embed template: failed to execute template: template: texttpl:1:14: executing "texttpl" ` + + `at : error calling call: call of nil` + if !strings.EqualFold(err.Error(), expectErr) { + t.Errorf("expected error to be %s, got: %s", expectErr, err.Error()) + } + }) + t.Run("EmbedTextTemplate with nil template", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err = message.EmbedTextTemplate("embed.html", nil, data) + if err == nil { + t.Fatal("expected error, got nil") + } + expectedErr := `failed to embed template: ` + errTplPointerNil + if !strings.EqualFold(err.Error(), expectedErr) { + t.Errorf("expected error to be %s, got: %s", expectedErr, err.Error()) + } + }) +} + +func TestMsg_EmbedFromEmbedFS(t *testing.T) { + t.Run("EmbedFromEmbedFS successful", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.EmbedFromEmbedFS("testdata/embed.txt", &efs, + WithFileName("embed.txt")); err != nil { + t.Fatalf("failed to embed from embed FS: %s", err) + } + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to retrieve embeds list") + } + if embeds[0] == nil { + t.Fatal("expected embed to be not nil") + } + if embeds[0].Name != "embed.txt" { + t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name) + } + messageBuf := bytes.NewBuffer(nil) + _, err := embeds[0].Writer(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.EqualFold(got, "This is a test embed") { + t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got) + } + }) + t.Run("EmbedFromEmbedFS with invalid path", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.EmbedFromEmbedFS("testdata/invalid.txt", &efs, WithFileName("embed.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) + t.Run("EmbedFromEmbedFS with nil embed FS", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + err := message.EmbedFromEmbedFS("testdata/invalid.txt", nil, WithFileName("embed.txt")) + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestMsg_Reset(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if err := message.From("toni.tester@example.com"); err != nil { + t.Fatalf("failed to set From address: %s", err) + } + if err := message.To("tina.tester@example.com"); err != nil { + t.Fatalf("failed to set To address: %s", err) + } + message.Subject("This is the subject") + message.SetBodyString(TypeTextPlain, "This is the body") + message.AddAlternativeString(TypeTextPlain, "This is the alternative string") + message.EmbedFile("testdata/embed.txt") + message.AttachFile("testdata/attach.txt") + + message.Reset() + if len(message.GetFromString()) != 0 { + t.Errorf("expected message From address to be empty, got: %+v", message.GetFromString()) + } + if len(message.GetToString()) != 0 { + t.Errorf("expected message To address to be empty, got: %+v", message.GetFromString()) + } + if len(message.GetGenHeader(HeaderSubject)) != 0 { + t.Errorf("expected message Subject to be empty, got: %+v", message.GetGenHeader(HeaderSubject)) + } + if len(message.GetAttachments()) != 0 { + t.Errorf("expected message Attachments to be empty, got: %d", len(message.GetAttachments())) + } + if len(message.GetEmbeds()) != 0 { + t.Errorf("expected message Embeds to be empty, got: %d", len(message.GetEmbeds())) + } + if len(message.GetParts()) != 0 { + t.Errorf("expected message Parts to be empty, got: %d", len(message.GetParts())) + } +} + +func TestMsg_applyMiddlewares(t *testing.T) { + t.Run("new message with middleware: uppercase", func(t *testing.T) { + tests := []struct { + subject string + want string + }{ + {"This is test subject", "THIS IS TEST SUBJECT"}, + {"This is also a test subject", "THIS IS ALSO A TEST SUBJECT"}, + } + + for _, tt := range tests { + t.Run(tt.subject, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if len(message.middlewares) != 0 { + t.Errorf("NewMsg() failed. Expected empty middlewares, got: %d", len(message.middlewares)) + } + message = NewMsg(WithMiddleware(uppercaseMiddleware{})) + if len(message.middlewares) != 1 { + t.Errorf("NewMsg(WithMiddleware(uppercaseMiddleware{})) failed. Expected 1 middleware, got: %d", + len(message.middlewares)) + } + message.Subject(tt.subject) + checkGenHeader(t, message, HeaderSubject, "applyMiddleware", 0, 1, tt.subject) + message = message.applyMiddlewares(message) + checkGenHeader(t, message, HeaderSubject, "applyMiddleware", 0, 1, tt.want) + }) + } + }) + t.Run("new message with middleware: encode", func(t *testing.T) { + tests := []struct { + subject string + want string + }{ + {"This is a test subject", "This is @ test subject"}, + {"This is also a test subject", "This is @lso @ test subject"}, + } + + for _, tt := range tests { + t.Run(tt.subject, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if len(message.middlewares) != 0 { + t.Errorf("NewMsg() failed. Expected empty middlewares, got: %d", len(message.middlewares)) + } + message = NewMsg(WithMiddleware(encodeMiddleware{})) + if len(message.middlewares) != 1 { + t.Errorf("NewMsg(WithMiddleware(encodeMiddleware{})) failed. Expected 1 middleware, got: %d", + len(message.middlewares)) + } + message.Subject(tt.subject) + checkGenHeader(t, message, HeaderSubject, "applyMiddleware", 0, 1, tt.subject) + message = message.applyMiddlewares(message) + checkGenHeader(t, message, HeaderSubject, "applyMiddleware", 0, 1, tt.want) + }) + } + }) + t.Run("new message with middleware: uppercase and encode", func(t *testing.T) { + tests := []struct { + subject string + want string + }{ + {"This is a test subject", "THIS IS @ TEST SUBJECT"}, + {"This is also a test subject", "THIS IS @LSO @ TEST SUBJECT"}, + } + + for _, tt := range tests { + t.Run(tt.subject, func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + if len(message.middlewares) != 0 { + t.Errorf("NewMsg() failed. Expected empty middlewares, got: %d", len(message.middlewares)) + } + message = NewMsg(WithMiddleware(encodeMiddleware{}), WithMiddleware(uppercaseMiddleware{})) + if len(message.middlewares) != 2 { + t.Errorf("NewMsg(WithMiddleware(encodeMiddleware{})) failed. Expected 2 middlewares, got: %d", + len(message.middlewares)) + } + message.Subject(tt.subject) + checkGenHeader(t, message, HeaderSubject, "applyMiddleware", 0, 1, tt.subject) + message = message.applyMiddlewares(message) + checkGenHeader(t, message, HeaderSubject, "applyMiddleware", 0, 1, tt.want) + }) + } + }) +} + +func TestMsg_WriteTo(t *testing.T) { + t.Run("WriteTo memory buffer with normal mail parts", func(t *testing.T) { + message := testMessage(t) + buffer := bytes.NewBuffer(nil) + if _, err := message.WriteTo(buffer); err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + parsed, err := EMLToMsgFromReader(buffer) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "Testmail") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + }) + t.Run("WriteTo fails to write", func(t *testing.T) { + message := testMessage(t) + _, err := message.WriteTo(failReadWriteSeekCloser{}) + if err == nil { + t.Fatalf("writing to failReadWriteSeekCloser should fail") + } + if strings.EqualFold(err.Error(), "failed to write message to buffer: intentional write failure") { + t.Fatalf("expected error to be: failed to write message to buffer: intentional write failure, got: %s", + err) + } + }) + t.Run("WriteTo with long headers", func(t *testing.T) { + message := testMessage(t) + message.SetGenHeader(HeaderContentLang, "de", "en", "fr", "es", "xxxx", "yyyy", "de", "en", "fr", + "es", "xxxx", "yyyy", "de", "en", "fr", "es", "xxxx", "yyyy", "de", "en", "fr") + message.SetGenHeader(HeaderContentID, "XXXXXXXXXXXXXXX XXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXX "+ + "XXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXX") + messageBuffer := bytes.NewBuffer(nil) + n, err := message.WriteTo(messageBuffer) + if err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + if n != int64(messageBuffer.Len()) { + t.Errorf("expected written bytes: %d, got: %d", n, messageBuffer.Len()) + } + }) + t.Run("WriteTo with multiple writes", func(t *testing.T) { + message := testMessage(t) + buffer := bytes.NewBuffer(nil) + messageBuf := bytes.NewBuffer(nil) + for i := 0; i < 10; i++ { + t.Run(fmt.Sprintf("write %d", i), func(t *testing.T) { + if _, err := message.WriteTo(buffer); err != nil { + t.Fatalf("failed to write message to buffer: %s", err) + } + parsed, err := EMLToMsgFromReader(buffer) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "Testmail") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + buffer.Reset() + }) + } + }) +} + +func TestMsg_WriteToFile(t *testing.T) { + t.Run("WriteToFile with normal mail parts", func(t *testing.T) { + tempfile, err := os.CreateTemp("", "testmail.*.eml") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + if err = tempfile.Close(); err != nil { + t.Fatalf("failed to close temp file: %s", err) + } + if err = os.Remove(tempfile.Name()); err != nil { + t.Fatalf("failed to remove temp file: %s", err) + } + + message := testMessage(t) + if err = message.WriteToFile(tempfile.Name()); err != nil { + t.Fatalf("failed to write message to tempfile %q: %s", tempfile.Name(), err) + } + parsed, err := EMLToMsgFromFile(tempfile.Name()) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "Testmail") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + }) +} + +func TestMsg_Write(t *testing.T) { + message := testMessage(t) + if _, err := message.Write(io.Discard); err != nil { + t.Fatalf("failed to write message to io.Discard: %s", err) + } + t.Log("Write() is just an alias to WriteTo(), which has already been tested.") +} + +func TestMsg_WriteToSkipMiddleware(t *testing.T) { + t.Run("WriteToSkipMiddleware with two middlewares, skipping uppercase", func(t *testing.T) { + message := NewMsg(WithMiddleware(encodeMiddleware{}), WithMiddleware(uppercaseMiddleware{})) + if message == nil { + t.Fatal("failed to create new message") + } + if err := message.From(TestSenderValid); err != nil { + t.Errorf("failed to set sender address: %s", err) + } + if err := message.To(TestRcptValid); err != nil { + t.Errorf("failed to set recipient address: %s", err) + } + message.Subject("This is a test subject") + message.SetBodyString(TypeTextPlain, "Testmail") + + buffer := bytes.NewBuffer(nil) + if _, err := message.WriteToSkipMiddleware(buffer, uppercaseMiddleware{}.Type()); err != nil { + t.Fatalf("failed to write message with middleware to buffer: %s", err) + } + parsed, err := EMLToMsgFromReader(buffer) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "This is @ test subject") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + }) +} + +// TestMsg_WriteToSendmailWithContext tests the WriteToSendmailWithContext() method of the Msg +func TestMsg_WriteToSendmailWithContext(t *testing.T) { + if os.Getenv("PERFORM_SENDMAIL_TESTS") != "true" { + t.Skipf("PERFORM_SENDMAIL_TESTS variable is not set to true, skipping sendmail test") + } + + if !hasSendmail() { + t.Skipf("sendmail binary not found, skipping test") + } + tests := []struct { + sendmailPath string + shouldFail bool + }{ + {"/dev/null", true}, + {"/bin/cat", true}, + {"/is/invalid", true}, + {SendmailPath, false}, + } + t.Run("WriteToSendmailWithContext on different paths", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.sendmailPath, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) + defer cancel() + + message := testMessage(t) + err := message.WriteToSendmailWithContext(ctx, tt.sendmailPath) + if err != nil && !tt.shouldFail { + t.Errorf("failed to write message to sendmail: %s", err) + } + if err == nil && tt.shouldFail { + t.Error("expected error, got nil") + } + }) + } + }) + t.Run("WriteToSendmailWithContext on canceled context", func(t *testing.T) { + if !hasSendmail() { + t.Skipf("sendmail binary not found, skipping test") + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) + cancel() + + message := testMessage(t) + if err := message.WriteToSendmailWithContext(ctx, SendmailPath); err == nil { + t.Fatalf("expected error on canceled context, got nil") + } + }) + t.Run("WriteToSendmailWithContext via WriteToSendmail", func(t *testing.T) { + if !hasSendmail() { + t.Skipf("sendmail binary not found, skipping test") + } + message := testMessage(t) + if err := message.WriteToSendmail(); err != nil { + t.Fatalf("failed to write message to sendmail: %s", err) + } + }) + t.Run("WriteToSendmailWithContext via WriteToSendmailWithCommand", func(t *testing.T) { + if !hasSendmail() { + t.Skipf("sendmail binary not found, skipping test") + } + message := testMessage(t) + if err := message.WriteToSendmailWithCommand(SendmailPath); err != nil { + t.Fatalf("failed to write message to sendmail: %s", err) + } + }) +} + +func TestMsg_NewReader(t *testing.T) { + t.Run("NewReader succeeds", func(t *testing.T) { + message := testMessage(t) + reader := message.NewReader() + if reader == nil { + t.Fatalf("failed to create message reader") + } + if reader.Error() != nil { + t.Errorf("failed to create message reader: %s", reader.Error()) + } + parsed, err := EMLToMsgFromReader(reader) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "Testmail") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + }) + t.Run("NewReader should fail on write", func(t *testing.T) { + message := testMessage(t) + if len(message.parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(message.parts)) + } + message.parts[0].writeFunc = func(io.Writer) (int64, error) { + return 0, errors.New("intentional write error") + } + reader := message.NewReader() + if reader == nil { + t.Fatalf("failed to create message reader") + } + if reader.Error() == nil { + t.Fatalf("expected error on write, got nil") + } + if !strings.EqualFold(reader.Error().Error(), `failed to write Msg to Reader buffer: bodyWriter function: `+ + `intentional write error`) { + t.Errorf("expected error to be %s, got: %s", `failed to write Msg to Reader buffer: bodyWriter function: `+ + `intentional write error`, reader.Error().Error()) + } + }) +} + +func TestMsg_UpdateReader(t *testing.T) { + t.Run("UpdateReader succeeds", func(t *testing.T) { + message := testMessage(t) + reader := message.NewReader() + if reader == nil { + t.Fatalf("failed to create message reader") + } + if reader.Error() != nil { + t.Errorf("failed to create message reader: %s", reader.Error()) + } + message.Subject("This is the actual subject") + message.UpdateReader(reader) + if reader == nil { + t.Fatalf("failed to create message reader") + } + if reader.Error() != nil { + t.Errorf("failed to create message reader: %s", reader.Error()) + } + parsed, err := EMLToMsgFromReader(reader) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "This is the actual subject") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + }) + t.Run("UpdateReader should fail on write", func(t *testing.T) { + message := testMessage(t) + reader := message.NewReader() + if reader == nil { + t.Fatalf("failed to create message reader") + } + if reader.Error() != nil { + t.Errorf("failed to create message reader: %s", reader.Error()) + } + message.Subject("This is the actual subject") + if len(message.parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(message.parts)) + } + message.parts[0].writeFunc = func(io.Writer) (int64, error) { + return 0, errors.New("intentional write error") + } + message.UpdateReader(reader) + if reader == nil { + t.Fatalf("failed to create message reader") + } + if reader.Error() == nil { + t.Fatalf("expected error on write, got nil") + } + if !strings.EqualFold(reader.Error().Error(), `bodyWriter function: intentional write error`) { + t.Errorf("expected error to be %s, got: %s", `bodyWriter function: intentional write error`, + reader.Error().Error()) + } + }) +} + +func TestMsg_HasSendError(t *testing.T) { + t.Run("HasSendError on unsent message", func(t *testing.T) { + message := testMessage(t) + if message.HasSendError() { + t.Error("HasSendError on unsent message should return false") + } + }) + t.Run("HasSendError on sent message", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err != nil { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + t.Skip("failed to connect to the test server due to timeout") + } + t.Fatalf("failed to connect to test server: %s", err) + } + t.Cleanup(func() { + if err := client.Close(); err != nil { + t.Errorf("failed to close client: %s", err) + } + }) + + if message.HasSendError() { + t.Error("HasSendError on sent message should return false") + } + }) + t.Run("HasSendError on failed message delivery", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataClose: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + if !message.HasSendError() { + t.Error("HasSendError on failed message delivery should return true") + } + }) + t.Run("HasSendError on failed message with SendError", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataClose: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + if !message.HasSendError() { + t.Fatal("HasSendError on failed message delivery should return true") + } + if message.SendError() == nil { + t.Fatal("SendError on failed message delivery should return SendErr") + } + var sendErr *SendError + if !errors.As(message.SendError(), &sendErr) { + t.Fatal("expected SendError to return a SendError type") + } + }) + t.Run("HasSendError with SendErrorIsTemp", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailOnDataClose: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + if !message.HasSendError() { + t.Error("HasSendError on failed message delivery should return true") + } + if message.SendErrorIsTemp() { + t.Error("SendErrorIsTemp on hard failed message delivery should return false") + } + }) + t.Run("HasSendError with SendErrorIsTemp on temp error", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailTemp: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + if !message.HasSendError() { + t.Error("HasSendError on failed message delivery should return true") + } + if !message.SendErrorIsTemp() { + t.Error("SendErrorIsTemp on temp failed message delivery should return true") + } + }) + t.Run("HasSendError with not a SendErr", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + PortAdder.Add(1) + serverPort := int(TestServerPortBase + PortAdder.Load()) + featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, t, &serverProps{ + FailTemp: true, + FeatureSet: featureSet, + ListenPort: serverPort, + }); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 30) + + client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS)) + if err != nil { + t.Fatalf("failed to create new client: %s", err) + } + + message := testMessage(t) + if err = client.DialAndSend(message); err == nil { + t.Error("message delivery was supposed to fail on data close") + } + message.sendError = errors.New("not a SendErr") + if !message.HasSendError() { + t.Error("HasSendError with not a SendErr should still return true") + } + if message.SendErrorIsTemp() { + t.Error("SendErrorIsTemp on not a SendErr should return false") + } + }) +} + +func TestMsg_WriteToTempFile(t *testing.T) { + if os.Getenv("PERFORM_UNIX_OPEN_WRITE_TESTS") != "true" { + t.Skipf("PERFORM_UNIX_OPEN_WRITE_TESTS variable is not set. Skipping unix open/write tests") + } + + t.Run("WriteToTempFile succeeds", func(t *testing.T) { + message := testMessage(t) + tempFile, err := message.WriteToTempFile() + if err != nil { + t.Fatalf("failed to write message to temp file: %s", err) + } + parsed, err := EMLToMsgFromFile(tempFile) + if err != nil { + t.Fatalf("failed to parse message in buffer: %s", err) + } + checkAddrHeader(t, parsed, HeaderFrom, "WriteTo", 0, 1, TestSenderValid, "") + checkAddrHeader(t, parsed, HeaderTo, "WriteTo", 0, 1, TestRcptValid, "") + checkGenHeader(t, parsed, HeaderSubject, "WriteTo", 0, 1, "Testmail") + parts := parsed.GetParts() + if len(parts) != 1 { + t.Fatalf("expected 1 parts, got: %d", len(parts)) + } + if parts[0].contentType != TypeTextPlain { + t.Errorf("expected contentType to be %s, got: %s", TypeTextPlain, parts[0].contentType) + } + if parts[0].encoding != EncodingQP { + t.Errorf("expected encoding to be %s, got: %s", EncodingQP, parts[0].encoding) + } + messageBuf := bytes.NewBuffer(nil) + _, err = parts[0].writeFunc(messageBuf) + if err != nil { + t.Errorf("writer func failed: %s", err) + } + got := strings.TrimSpace(messageBuf.String()) + if !strings.HasSuffix(got, "Testmail") { + t.Errorf("expected message buffer to contain Testmail, got: %s", got) + } + }) +} + +func TestMsg_hasAlt(t *testing.T) { + t.Run("message has no alt", func(t *testing.T) { + message := testMessage(t) + if message.hasAlt() { + t.Error("message has no alt, but hasAlt returned true") + } + }) + t.Run("message has alt", func(t *testing.T) { + message := testMessage(t) + message.AddAlternativeString(TypeTextHTML, "this is the alt") + if !message.hasAlt() { + t.Error("message has alt, but hasAlt returned false") + } + }) + t.Run("message has no alt due to deleted part", func(t *testing.T) { + message := testMessage(t) + message.AddAlternativeString(TypeTextHTML, "this is the alt") + if len(message.GetParts()) != 2 { + t.Errorf("expected message to have 2 parts, got: %d", len(message.GetParts())) + } + message.parts[1].isDeleted = true + if message.hasAlt() { + t.Error("message has no alt, but hasAlt returned true") + } + }) + t.Run("message has alt and deleted parts", func(t *testing.T) { + message := testMessage(t) + message.AddAlternativeString(TypeTextHTML, "this is the alt") + message.AddAlternativeString(TypeTextPlain, "this is also a alt") + if len(message.GetParts()) != 3 { + t.Errorf("expected message to have 3 parts, got: %d", len(message.GetParts())) + } + message.parts[1].isDeleted = true + if !message.hasAlt() { + t.Error("message has alt, but hasAlt returned false") + } + }) + t.Run("message has alt but it is pgptype", func(t *testing.T) { + message := testMessage(t) + message.SetPGPType(PGPSignature) + if message.hasAlt() { + t.Error("message has alt but it is a pgpType, hence hasAlt should return false") + } + }) +} + +func TestMsg_hasMixed(t *testing.T) { + t.Run("message has no mixed", func(t *testing.T) { + message := testMessage(t) + if message.hasMixed() { + t.Error("message has no mixed, but hasMixed returned true") + } + }) + t.Run("message with alt has no mixed", func(t *testing.T) { + message := testMessage(t) + message.AddAlternativeString(TypeTextHTML, "this is the alt") + if message.hasMixed() { + t.Error("message has no mixed, but hasMixed returned true") + } + }) + t.Run("message with attachment is mixed", func(t *testing.T) { + message := testMessage(t) + message.AttachFile("testdata/attachment.txt") + if !message.hasMixed() { + t.Error("message with attachment is supposed to be mixed") + } + }) + t.Run("message with embed is not mixed", func(t *testing.T) { + message := testMessage(t) + message.EmbedFile("testdata/embed.txt") + if message.hasMixed() { + t.Error("message with embed is not supposed to be mixed") + } + }) + t.Run("message with attachment and embed is mixed", func(t *testing.T) { + message := testMessage(t) + message.AttachFile("testdata/attachment.txt") + message.EmbedFile("testdata/embed.txt") + if !message.hasMixed() { + t.Error("message with attachment and embed is supposed to be mixed") + } + }) +} + +func TestMsg_hasRelated(t *testing.T) { + t.Run("message has no related", func(t *testing.T) { + message := testMessage(t) + if message.hasRelated() { + t.Error("message has no related, but hasRelated returned true") + } + }) + t.Run("message with alt has no related", func(t *testing.T) { + message := testMessage(t) + message.AddAlternativeString(TypeTextHTML, "this is the alt") + if message.hasRelated() { + t.Error("message has no related, but hasRelated returned true") + } + }) + t.Run("message with attachment is not related", func(t *testing.T) { + message := testMessage(t) + message.AttachFile("testdata/attachment.txt") + if message.hasRelated() { + t.Error("message with attachment is not supposed to be related") + } + }) + t.Run("message with embed is related", func(t *testing.T) { + message := testMessage(t) + message.EmbedFile("testdata/embed.txt") + if !message.hasRelated() { + t.Error("message with embed is supposed to be related") + } + }) + t.Run("message with attachment and embed is related", func(t *testing.T) { + message := testMessage(t) + message.AttachFile("testdata/attachment.txt") + message.EmbedFile("testdata/embed.txt") + if !message.hasRelated() { + t.Error("message with attachment and embed is supposed to be related") + } + }) +} + +func TestMsg_hasPGPType(t *testing.T) { + t.Run("message has no pgpType", func(t *testing.T) { + message := testMessage(t) + if message.hasPGPType() { + t.Error("message has no PGPType, but hasPGPType returned true") + } + }) + t.Run("message has signature", func(t *testing.T) { + message := testMessage(t) + message.SetPGPType(PGPSignature) + if !message.hasPGPType() { + t.Error("message has signature, but hasPGPType returned false") + } + }) + t.Run("message has encryption", func(t *testing.T) { + message := testMessage(t) + message.SetPGPType(PGPEncrypt) + if !message.hasPGPType() { + t.Error("message has encryption, but hasPGPType returned false") + } + }) + t.Run("message has encryption and signature", func(t *testing.T) { + message := testMessage(t) + message.SetPGPType(PGPEncrypt | PGPSignature) + if !message.hasPGPType() { + t.Error("message has encryption and signature, but hasPGPType returned false") + } + }) + t.Run("message has NoPGP", func(t *testing.T) { + message := testMessage(t) + message.SetPGPType(NoPGP) + if message.hasPGPType() { + t.Error("message has NoPGP, but hasPGPType returned true") + } + }) +} + +func TestMsg_checkUserAgent(t *testing.T) { + t.Run("default user agent should be set", func(t *testing.T) { + message := testMessage(t) + message.checkUserAgent() + checkGenHeader(t, message, HeaderUserAgent, "checkUserAgent", 0, 1, + fmt.Sprintf("go-mail v%s // https://github.com/wneessen/go-mail", VERSION)) + checkGenHeader(t, message, HeaderXMailer, "checkUserAgent", 0, 1, + fmt.Sprintf("go-mail v%s // https://github.com/wneessen/go-mail", VERSION)) + }) + t.Run("noDefaultUserAgent should return empty string", func(t *testing.T) { + message := testMessage(t) + message.noDefaultUserAgent = true + message.checkUserAgent() + if len(message.genHeader[HeaderUserAgent]) != 0 { + t.Error("user agent should be empty") + } + if len(message.genHeader[HeaderXMailer]) != 0 { + t.Error("x-mailer should be empty") + } + }) +} + +func TestMsg_addDefaultHeader(t *testing.T) { + t.Run("empty message should add defaults", func(t *testing.T) { + message := NewMsg() + if message == nil { + t.Fatal("failed to create new message") + } + if _, ok := message.genHeader[HeaderDate]; ok { + t.Error("empty message should not have date header") + } + if _, ok := message.genHeader[HeaderMessageID]; ok { + t.Error("empty message should not have message-id header") + } + if _, ok := message.genHeader[HeaderMIMEVersion]; ok { + t.Error("empty message should not have mime version header") + } + message.addDefaultHeader() + if _, ok := message.genHeader[HeaderDate]; !ok { + t.Error("message should now have date header") + } + if _, ok := message.genHeader[HeaderMessageID]; !ok { + t.Error("message should now have message-id header") + } + if _, ok := message.genHeader[HeaderMIMEVersion]; !ok { + t.Error("message should now have mime version header") + } + }) +} + +// uppercaseMiddleware is a middleware type that transforms the subject to uppercase. type uppercaseMiddleware struct{} +// Handle satisfies the Middleware interface for the uppercaseMiddlware func (mw uppercaseMiddleware) Handle(m *Msg) *Msg { s, ok := m.genHeader[HeaderSubject] if !ok { @@ -223,12 +6456,15 @@ func (mw uppercaseMiddleware) Handle(m *Msg) *Msg { return m } +// Type satisfies the Middleware interface for the uppercaseMiddlware func (mw uppercaseMiddleware) Type() MiddlewareType { return "uppercase" } +// encodeMiddleware is a middleware type that transforms an "a" in the subject to an "@" type encodeMiddleware struct{} +// Handle satisfies the Middleware interface for the encodeMiddleware func (mw encodeMiddleware) Handle(m *Msg) *Msg { s, ok := m.genHeader[HeaderSubject] if !ok { @@ -241,3009 +6477,89 @@ func (mw encodeMiddleware) Handle(m *Msg) *Msg { return m } +// Type satisfies the Middleware interface for the encodeMiddleware func (mw encodeMiddleware) Type() MiddlewareType { return "encode" } -// TestNewMsgWithMiddleware tests WithMiddleware -func TestNewMsgWithMiddleware(t *testing.T) { - m := NewMsg() - if len(m.middlewares) != 0 { - t.Errorf("empty middlewares failed. m.middlewares expected to be: empty, got: %d middleware", len(m.middlewares)) - } - m = NewMsg(WithMiddleware(uppercaseMiddleware{})) - if len(m.middlewares) != 1 { - t.Errorf("empty middlewares failed. m.middlewares expected to be: 1, got: %d middleware", len(m.middlewares)) - } - m = NewMsg(WithMiddleware(uppercaseMiddleware{}), WithMiddleware(encodeMiddleware{})) - if len(m.middlewares) != 2 { - t.Errorf("empty middlewares failed. m.middlewares expected to be: 2, got: %d middleware", len(m.middlewares)) - } +// failReadWriteSeekCloser is a type that always returns an error. It satisfies the io.Reader, io.Writer +// io.Closer, io.Seeker, io.WriteSeeker, io.ReadSeeker, io.ReadCloser and io.WriteCloser interfaces +type failReadWriteSeekCloser struct{} + +// Write satisfies the io.Writer interface for the failReadWriteSeekCloser type +func (failReadWriteSeekCloser) Write([]byte) (int, error) { + return 0, errors.New("intentional write failure") } -// TestApplyMiddlewares tests the applyMiddlewares for the Msg object -func TestApplyMiddlewares(t *testing.T) { - tests := []struct { - name string - sub string - want string - }{ - {"normal subject", "This is a test subject", "THIS IS @ TEST SUBJECT"}, - {"subject with one middleware effect", "This is test subject", "THIS IS TEST SUBJECT"}, - {"subject with one middleware effect", "This is A test subject", "THIS IS A TEST SUBJECT"}, - } - m := NewMsg(WithMiddleware(encodeMiddleware{}), WithMiddleware(uppercaseMiddleware{})) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.Subject(tt.sub) - if m.genHeader[HeaderSubject] == nil { - t.Errorf("Subject() method failed in applyMiddlewares() test. Generic header for subject is empty") - return - } - m = m.applyMiddlewares(m) - s, ok := m.genHeader[HeaderSubject] - if !ok { - t.Errorf("failed to get subject header") - } - if s[0] != tt.want { - t.Errorf("applyMiddlewares() method failed. Expected: %s, got: %s", tt.want, s[0]) - } - }) - } +// Read satisfies the io.Reader interface for the failReadWriteSeekCloser type +func (failReadWriteSeekCloser) Read([]byte) (int, error) { + return 0, errors.New("intentional read failure") } -// TestMsg_SetGenHeader tests Msg.SetGenHeader -func TestMsg_SetGenHeader(t *testing.T) { - tests := []struct { - name string - header Header - values []string - }{ - {"set subject", HeaderSubject, []string{"This is Subject"}}, - {"set content-language", HeaderContentLang, []string{"en", "de", "fr", "es"}}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMsg() - m.SetGenHeader(tt.header, tt.values...) - if m.genHeader[tt.header] == nil { - t.Errorf("SetGenHeader() failed. Tried to set header %s, but it is empty", tt.header) - return - } - for _, v := range tt.values { - found := false - for _, hv := range m.genHeader[tt.header] { - if hv == v { - found = true - } - } - if !found { - t.Errorf("SetGenHeader() failed. Value %s not found in header field", v) - } - } - }) - } +// Seek satisfies the io.Seeker interface for the failReadWriteSeekCloser type +func (failReadWriteSeekCloser) Seek(int64, int) (int64, error) { + return 0, errors.New("intentional seek failure") } -// TestMsg_SetGenHeaderPreformatted tests Msg.SetGenHeaderPreformatted -func TestMsg_SetGenHeaderPreformatted(t *testing.T) { - tests := []struct { - name string - header Header - value string - }{ - {"set subject", HeaderSubject, "This is Subject"}, - {"set content-language", HeaderContentLang, fmt.Sprintf("%s, %s, %s, %s", - "en", "de", "fr", "es")}, - {"set subject with newline", HeaderSubject, "This is Subject\r\n with 2nd line"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := &Msg{} - m.SetGenHeaderPreformatted(tt.header, tt.value) - m = NewMsg() - m.SetGenHeaderPreformatted(tt.header, tt.value) - if m.preformHeader[tt.header] == "" { - t.Errorf("SetGenHeaderPreformatted() failed. Tried to set header %s, but it is empty", tt.header) - } - if m.preformHeader[tt.header] != tt.value { - t.Errorf("SetGenHeaderPreformatted() failed. Expected: %q, got: %q", tt.value, - m.preformHeader[tt.header]) - } - buf := bytes.Buffer{} - _, err := m.WriteTo(&buf) - if err != nil { - t.Errorf("failed to write message to memory: %s", err) - return - } - if !strings.Contains(buf.String(), fmt.Sprintf("%s: %s%s", tt.header, tt.value, SingleNewLine)) { - t.Errorf("SetGenHeaderPreformatted() failed. Unable to find correctly formated header in " + - "mail message output") - } - }) - } +// Close satisfies the io.Closer interface for the failReadWriteSeekCloser type +func (failReadWriteSeekCloser) Close() error { + return errors.New("intentional close failure") } -// TestMsg_AddTo tests the Msg.AddTo method -func TestMsg_AddTo(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - na := "address3@example.com" - m := NewMsg() - if err := m.To(a...); err != nil { - t.Errorf("failed to set TO addresses: %s", err) - return - } - if err := m.AddTo(na); err != nil { - t.Errorf("AddTo failed: %s", err) - return - } - - atf := false - for _, v := range m.addrHeader[HeaderTo] { - if v.Address == na { - atf = true - } - } - if !atf { - t.Errorf("AddTo() failed. Address %q not found in TO address slice.", na) - } -} - -// TestMsg_From tests the Msg.From and Msg.GetSender methods -func TestMsg_From(t *testing.T) { - a := "toni@example.com" - n := "Toni Tester" - na := fmt.Sprintf(`"%s" <%s>`, n, a) - m := NewMsg() - - _, err := m.GetSender(false) - if err == nil { - t.Errorf("GetSender(false) without a set From address succeeded but was expected to fail") - return - } - - if err := m.From(na); err != nil { - t.Errorf("failed to set FROM addresses: %s", err) - return - } - gs, err := m.GetSender(false) - if err != nil { - t.Errorf("GetSender(false) failed: %s", err) - return - } - if gs != a { - t.Errorf("From() failed. Expected: %s, got: %s", a, gs) - return - } - - gs, err = m.GetSender(true) - if err != nil { - t.Errorf("GetSender(true) failed: %s", err) - return - } - if gs != na { - t.Errorf("From() failed. Expected: %s, got: %s", na, gs) - return - } -} - -// TestMsg_EnvelopeFrom tests the Msg.EnvelopeFrom and Msg.GetSender methods -func TestMsg_EnvelopeFrom(t *testing.T) { - e := "envelope@example.com" - a := "toni@example.com" - n := "Toni Tester" - na := fmt.Sprintf(`"%s" <%s>`, n, a) - ne := fmt.Sprintf(`<%s>`, e) - m := NewMsg() - - _, err := m.GetSender(false) - if err == nil { - t.Errorf("GetSender(false) without a set envelope From address succeeded but was expected to fail") - return - } - - if err := m.EnvelopeFrom(e); err != nil { - t.Errorf("failed to set envelope FROM addresses: %s", err) - return - } - gs, err := m.GetSender(false) - if err != nil { - t.Errorf("GetSender(false) failed: %s", err) - return - } - if gs != e { - t.Errorf("From() failed. Expected: %s, got: %s", e, gs) - return - } - - if err := m.From(na); err != nil { - t.Errorf("failed to set FROM addresses: %s", err) - return - } - gs, err = m.GetSender(false) - if err != nil { - t.Errorf("GetSender(false) failed: %s", err) - return - } - if gs != e { - t.Errorf("From() failed. Expected: %s, got: %s", e, gs) - return - } - - gs, err = m.GetSender(true) - if err != nil { - t.Errorf("GetSender(true) failed: %s", err) - return - } - if gs != ne { - t.Errorf("From() failed. Expected: %s, got: %s", ne, gs) - return - } - m.Reset() - - if err := m.From(na); err != nil { - t.Errorf("failed to set FROM addresses: %s", err) - return - } - gs, err = m.GetSender(false) - if err != nil { - t.Errorf("GetSender(true) failed: %s", err) - return - } - if gs != a { - t.Errorf("From() failed. Expected: %s, got: %s", a, gs) - return - } - gs, err = m.GetSender(true) - if err != nil { - t.Errorf("GetSender(true) failed: %s", err) - return - } - if gs != na { - t.Errorf("From() failed. Expected: %s, got: %s", na, gs) - return - } -} - -// TestMsg_AddToFormat tests the Msg.AddToFormat method -func TestMsg_AddToFormat(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - nn := "Toni Tester" - na := "address3@example.com" - w := `"Toni Tester" ` - m := NewMsg() - if err := m.To(a...); err != nil { - t.Errorf("failed to set TO addresses: %s", err) - return - } - if err := m.AddToFormat(nn, na); err != nil { - t.Errorf("AddToFormat failed: %s", err) - return - } - - atf := false - for _, v := range m.addrHeader[HeaderTo] { - if v.String() == w { - atf = true - } - } - if !atf { - t.Errorf("AddToFormat() failed. Address %q not found in TO address slice.", w) - } -} - -// TestMsg_ToIgnoreInvalid tests the Msg.ToIgnoreInvalid method -func TestMsg_ToIgnoreInvalid(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - fa := []string{"address1@example.com", "address2@example.com", "failedaddress.com"} - m := NewMsg() - - m.ToIgnoreInvalid(a...) - l := len(m.addrHeader[HeaderTo]) - if l != len(a) { - t.Errorf("ToIgnoreInvalid() failed. Expected %d addresses, got: %d", len(a), l) - } - m.ToIgnoreInvalid(fa...) - l = len(m.addrHeader[HeaderTo]) - if l != len(fa)-1 { - t.Errorf("ToIgnoreInvalid() failed. Expected %d addresses, got: %d", len(fa)-1, l) - } -} - -// TestMsg_AddCc tests the Msg.AddCc method -func TestMsg_AddCc(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - na := "address3@example.com" - m := NewMsg() - if err := m.Cc(a...); err != nil { - t.Errorf("failed to set CC addresses: %s", err) - return - } - if err := m.AddCc(na); err != nil { - t.Errorf("AddCc failed: %s", err) - return - } - - atf := false - for _, v := range m.addrHeader[HeaderCc] { - if v.Address == na { - atf = true - } - } - if !atf { - t.Errorf("AddCc() failed. Address %q not found in CC address slice.", na) - } -} - -// TestMsg_AddCcFormat tests the Msg.AddCcFormat method -func TestMsg_AddCcFormat(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - nn := "Toni Tester" - na := "address3@example.com" - w := `"Toni Tester" ` - m := NewMsg() - if err := m.Cc(a...); err != nil { - t.Errorf("failed to set CC addresses: %s", err) - return - } - if err := m.AddCcFormat(nn, na); err != nil { - t.Errorf("AddCcFormat failed: %s", err) - return - } - - atf := false - for _, v := range m.addrHeader[HeaderCc] { - if v.String() == w { - atf = true - } - } - if !atf { - t.Errorf("AddCcFormat() failed. Address %q not found in CC address slice.", w) - } -} - -// TestMsg_CcIgnoreInvalid tests the Msg.CcIgnoreInvalid method -func TestMsg_CcIgnoreInvalid(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - fa := []string{"address1@example.com", "address2@example.com", "failedaddress.com"} - m := NewMsg() - - m.CcIgnoreInvalid(a...) - l := len(m.addrHeader[HeaderCc]) - if l != len(a) { - t.Errorf("CcIgnoreInvalid() failed. Expected %d addresses, got: %d", len(a), l) - } - m.CcIgnoreInvalid(fa...) - l = len(m.addrHeader[HeaderCc]) - if l != len(fa)-1 { - t.Errorf("CcIgnoreInvalid() failed. Expected %d addresses, got: %d", len(fa)-1, l) - } -} - -// TestMsg_AddBcc tests the Msg.AddBcc method -func TestMsg_AddBcc(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - na := "address3@example.com" - m := NewMsg() - if err := m.Bcc(a...); err != nil { - t.Errorf("failed to set BCC addresses: %s", err) - return - } - if err := m.AddBcc(na); err != nil { - t.Errorf("AddBcc failed: %s", err) - return - } - - atf := false - for _, v := range m.addrHeader[HeaderBcc] { - if v.Address == na { - atf = true - } - } - if !atf { - t.Errorf("AddBcc() failed. Address %q not found in BCC address slice.", na) - } -} - -// TestMsg_AddBccFormat tests the Msg.AddBccFormat method -func TestMsg_AddBccFormat(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - nn := "Toni Tester" - na := "address3@example.com" - w := `"Toni Tester" ` - m := NewMsg() - if err := m.Bcc(a...); err != nil { - t.Errorf("failed to set BCC addresses: %s", err) - return - } - if err := m.AddBccFormat(nn, na); err != nil { - t.Errorf("AddBccFormat failed: %s", err) - return - } - - atf := false - for _, v := range m.addrHeader[HeaderBcc] { - if v.String() == w { - atf = true - } - } - if !atf { - t.Errorf("AddBccFormat() failed. Address %q not found in BCC address slice.", w) - } -} - -// TestMsg_BccIgnoreInvalid tests the Msg.BccIgnoreInvalid method -func TestMsg_BccIgnoreInvalid(t *testing.T) { - a := []string{"address1@example.com", "address2@example.com"} - fa := []string{"address1@example.com", "address2@example.com", "failedaddress.com"} - m := NewMsg() - - m.BccIgnoreInvalid(a...) - l := len(m.addrHeader[HeaderBcc]) - if l != len(a) { - t.Errorf("BccIgnoreInvalid() failed. Expected %d addresses, got: %d", len(a), l) - } - m.BccIgnoreInvalid(fa...) - l = len(m.addrHeader[HeaderBcc]) - if l != len(fa)-1 { - t.Errorf("BccIgnoreInvalid() failed. Expected %d addresses, got: %d", len(fa)-1, l) - } -} - -// TestMsg_SetBulk tests the Msg.SetBulk method -func TestMsg_SetBulk(t *testing.T) { - m := NewMsg() - m.SetBulk() - if m.genHeader[HeaderPrecedence] == nil { - t.Errorf("SetBulk() failed. Precedence header is nil") - return - } - if m.genHeader[HeaderPrecedence][0] != "bulk" { - t.Errorf("SetBulk() failed. Expected Precedence header: %q, got: %q", "bulk", - m.genHeader[HeaderPrecedence][0]) - } - if m.genHeader[HeaderXAutoResponseSuppress] == nil { - t.Errorf("SetBulk() failed. X-Auto-Response-Suppress header is nil") - return - } - if m.genHeader[HeaderXAutoResponseSuppress][0] != "All" { - t.Errorf("SetBulk() failed. Expected X-Auto-Response-Suppress header: %q, got: %q", "All", - m.genHeader[HeaderXAutoResponseSuppress][0]) - } -} - -// TestMsg_SetDate tests the Msg.SetDate and Msg.SetDateWithValue method -func TestMsg_SetDate(t *testing.T) { - m := NewMsg() - m.SetDate() - if m.genHeader[HeaderDate] == nil { - t.Errorf("SetDate() failed. Date header is nil") - return - } - d, ok := m.genHeader[HeaderDate] +// checkAddrHeader verifies the correctness of an AddrHeader in a Msg based on the provided criteria. +// It checks whether the AddrHeader contains the correct address, name, and number of fields. +func checkAddrHeader(t *testing.T, message *Msg, header AddrHeader, fn string, field, wantFields int, + wantMail, wantName string, +) { + t.Helper() + addresses, ok := message.addrHeader[header] if !ok { - t.Errorf("failed to get date header") - return + t.Fatalf("failed to exec %s, addrHeader field is not set", fn) } - _, err := time.Parse(time.RFC1123Z, d[0]) - if err != nil { - t.Errorf("failed to parse time in date header: %s", err) + if len(addresses) != wantFields { + t.Fatalf("failed to exec %s, addrHeader value count is: %d, want: %d", fn, len(addresses), field) } - m.genHeader = nil - m.genHeader = make(map[Header][]string) + if addresses[field].Address != wantMail { + t.Errorf("failed to exec %s, addrHeader value is %s, want: %s", fn, addresses[field].Address, wantMail) + } + wantString := fmt.Sprintf(`<%s>`, wantMail) + if wantName != "" { + wantString = fmt.Sprintf(`%q <%s>`, wantName, wantMail) + } + if addresses[field].String() != wantString { + t.Errorf("failed to exec %s, addrHeader value is %s, want: %s", fn, addresses[field].String(), wantString) + } + if addresses[field].Name != wantName { + t.Errorf("failed to exec %s, addrHeader name is %s, want: %s", fn, addresses[field].Name, wantName) + } +} - now := time.Now() - m.SetDateWithValue(now) - if m.genHeader[HeaderDate] == nil { - t.Errorf("SetDateWithValue() failed. Date header is nil") - return - } - d, ok = m.genHeader[HeaderDate] +// checkGenHeader validates the generated header in an email message, verifying its presence and expected values. +func checkGenHeader(t *testing.T, message *Msg, header Header, fn string, field, wantFields int, + wantVal string, +) { + t.Helper() + values, ok := message.genHeader[header] if !ok { - t.Errorf("failed to get date header") - return + t.Fatalf("failed to exec %s, genHeader field is not set", fn) } - pt, err := time.Parse(time.RFC1123Z, d[0]) - if err != nil { - t.Errorf("failed to parse time in date header: %s", err) + if len(values) != wantFields { + t.Fatalf("failed to exec %s, genHeader value count is: %d, want: %d", fn, len(values), field) } - if pt.Unix() != now.Unix() { - t.Errorf("SetDateWithValue() failed. Expected time: %d, got: %d", now.Unix(), - pt.Unix()) + if values[field] != wantVal { + t.Errorf("failed to exec %s, genHeader value is %s, want: %s", fn, values[field], wantVal) } } -// TestMsg_SetMessageIDWIthValue tests the Msg.SetMessageIDWithValue and Msg.SetMessageID methods -func TestMsg_SetMessageIDWithValue(t *testing.T) { - m := NewMsg() - m.SetMessageID() - if m.genHeader[HeaderMessageID] == nil { - t.Errorf("SetMessageID() failed. MessageID header is nil") - return - } - if m.genHeader[HeaderMessageID][0] == "" { - t.Errorf("SetMessageID() failed. Expected value, got: empty") - return - } - if _, ok := m.genHeader[HeaderMessageID]; ok { - m.genHeader[HeaderMessageID] = nil - } - v := "This.is.a.message.id" - vf := "" - m.SetMessageIDWithValue(v) - if m.genHeader[HeaderMessageID] == nil { - t.Errorf("SetMessageIDWithValue() failed. MessageID header is nil") - return - } - if m.genHeader[HeaderMessageID][0] != vf { - t.Errorf("SetMessageIDWithValue() failed. Expected: %s, got: %s", vf, m.genHeader[HeaderMessageID][0]) - return - } -} - -// TestMsg_SetMessageIDRandomness tests the randomness of Msg.SetMessageID methods -func TestMsg_SetMessageIDRandomness(t *testing.T) { - var mids []string - m := NewMsg() - for i := 0; i < 50_000; i++ { - m.SetMessageID() - mid := m.GetMessageID() - mids = append(mids, mid) - } - c := make(map[string]int) - for i := range mids { - c[mids[i]]++ - } - for k, v := range c { - if v > 1 { - t.Errorf("MessageID randomness not given. MessageID %q was generated %d times", k, v) - } - } -} - -func TestMsg_GetMessageID(t *testing.T) { - expected := "this.is.a.message.id" - msg := NewMsg() - msg.SetMessageIDWithValue(expected) - val := msg.GetMessageID() - if !strings.EqualFold(val, fmt.Sprintf("<%s>", expected)) { - t.Errorf("GetMessageID() failed. Expected: %s, got: %s", fmt.Sprintf("<%s>", expected), val) - } - msg.genHeader[HeaderMessageID] = nil - val = msg.GetMessageID() - if val != "" { - t.Errorf("GetMessageID() failed. Expected empty string, got: %s", val) - } -} - -// TestMsg_FromFormat tests the FromFormat and EnvelopeFrom methods for the Msg object -func TestMsg_FromFormat(t *testing.T) { - tests := []struct { - tname string - name string - addr string - want string - fail bool - }{ - { - "valid name and addr", "Toni Tester", "tester@example.com", - `"Toni Tester" `, false, - }, - { - "no name with valid addr", "", "tester@example.com", - ``, false, - }, - { - "valid name with invalid addr", "Toni Tester", "@example.com", - ``, true, - }, - } - - m := NewMsg() - for _, tt := range tests { - t.Run(tt.tname, func(t *testing.T) { - if err := m.FromFormat(tt.name, tt.addr); err != nil && !tt.fail { - t.Errorf("failed to FromFormat(): %s", err) - return - } - if err := m.EnvelopeFromFormat(tt.name, tt.addr); err != nil && !tt.fail { - t.Errorf("failed to EnvelopeFromFormat(): %s", err) - return - } - - var fa *mail.Address - f, ok := m.addrHeader[HeaderFrom] - if ok && len(f) > 0 { - fa = f[0] - } - if (!ok || len(f) == 0) && !tt.fail { - t.Errorf(`valid from address expected, but "From:" field is empty`) - return - } - if tt.fail && len(f) > 0 { - t.Errorf("FromFormat() was supposed to failed but got value: %s", fa.String()) - return - } - - if !tt.fail && fa.String() != tt.want { - t.Errorf("wrong result for FromFormat(). Want: %s, got: %s", tt.want, fa.String()) - } - m.addrHeader[HeaderFrom] = nil - }) - } -} - -func TestMsg_GetRecipients(t *testing.T) { - a := []string{"to@example.com", "cc@example.com", "bcc@example.com"} - m := NewMsg() - - _, err := m.GetRecipients() +// hasSendmail checks if the /usr/sbin/sendmail file exists and is executable. Returns true if conditions are met. +func hasSendmail() bool { + sm, err := os.Stat(SendmailPath) if err == nil { - t.Errorf("GetRecipients() succeeded but was expected to fail") - return - } - - if err := m.AddTo(a[0]); err != nil { - t.Errorf("AddTo() failed: %s", err) - return - } - if err := m.AddCc(a[1]); err != nil { - t.Errorf("AddCc() failed: %s", err) - return - } - if err := m.AddBcc(a[2]); err != nil { - t.Errorf("AddBcc() failed: %s", err) - return - } - - al, err := m.GetRecipients() - if err != nil { - t.Errorf("GetRecipients() failed: %s", err) - return - } - - tf, cf, bf := false, false, false - for _, r := range al { - if r == a[0] { - tf = true - } - if r == a[1] { - cf = true - } - if r == a[2] { - bf = true + if sm.Mode()&0o111 != 0 { + return true } } - if !tf { - t.Errorf("GetRecipients() failed. Expected to address %s but was not found", a[0]) - return - } - if !cf { - t.Errorf("GetRecipients() failed. Expected cc address %s but was not found", a[1]) - return - } - if !bf { - t.Errorf("GetRecipients() failed. Expected bcc address %s but was not found", a[2]) - return - } -} - -// TestMsg_ReplyTo tests the Msg.ReplyTo and Msg.ReplyToFormat methods -func TestMsg_ReplyTo(t *testing.T) { - tests := []struct { - tname string - name string - addr string - want string - sf bool - }{ - { - "valid name and addr", "Toni Tester", "tester@example.com", - `"Toni Tester" `, false, - }, - { - "no name with valid addr", "", "tester@example.com", - ``, false, - }, - { - "valid name with invalid addr", "Toni Tester", "@example.com", - ``, true, - }, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.tname, func(t *testing.T) { - if err := m.ReplyTo(tt.want); err != nil && !tt.sf { - t.Errorf("ReplyTo() method failed: %s", err) - } - if !tt.sf { - rt, ok := m.genHeader[HeaderReplyTo] - if !ok { - t.Errorf("ReplyTo() failed: ReplyTo generic header not set") - return - } - if len(rt) <= 0 { - t.Errorf("ReplyTo() failed: length of generic ReplyTo header is zero or less than zero") - return - } - if rt[0] != tt.want { - t.Errorf("ReplyTo() failed: expected value: %s, got: %s", tt.want, rt[0]) - } - } - m.genHeader = nil - m.genHeader = make(map[Header][]string) - if err := m.ReplyToFormat(tt.name, tt.addr); err != nil && !tt.sf { - t.Errorf("ReplyToFormat() method failed: %s", err) - } - if !tt.sf { - rt, ok := m.genHeader[HeaderReplyTo] - if !ok { - t.Errorf("ReplyTo() failed: ReplyTo generic header not set") - return - } - if len(rt) <= 0 { - t.Errorf("ReplyTo() failed: length of generic ReplyTo header is zero or less than zero") - return - } - if rt[0] != tt.want { - t.Errorf("ReplyTo() failed: expected value: %s, got: %s", tt.want, rt[0]) - } - } - m.genHeader = nil - m.genHeader = make(map[Header][]string) - }) - } -} - -// TestMsg_Subject tests the Msg.Subject method -func TestMsg_Subject(t *testing.T) { - tests := []struct { - name string - sub string - want string - }{ - {"normal subject", "This is a test subject", "This is a test subject"}, - { - "subject with umlauts", "This is a test subject with umlauts: üäöß", - "=?UTF-8?q?This_is_a_test_subject_with_umlauts:_=C3=BC=C3=A4=C3=B6=C3=9F?=", - }, - { - "subject with emoji", "This is a test subject with emoji: 📧", - "=?UTF-8?q?This_is_a_test_subject_with_emoji:_=F0=9F=93=A7?=", - }, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.Subject(tt.sub) - s, ok := m.genHeader[HeaderSubject] - if !ok || len(s) <= 0 { - t.Errorf("Subject() method failed. Generic header for Subject is empty") - return - } - if s[0] != tt.want { - t.Errorf("Subject() method failed. Expected: %s, got: %s", tt.want, s[0]) - } - }) - } -} - -// TestMsg_SetImportance tests the Msg.SetImportance method -func TestMsg_SetImportance(t *testing.T) { - tests := []struct { - name string - imp Importance - wantns string - xprio string - want string - sf bool - }{ - {"Importance: Non-Urgent", ImportanceNonUrgent, "0", "5", "non-urgent", false}, - {"Importance: Low", ImportanceLow, "0", "5", "low", false}, - {"Importance: Normal", ImportanceNormal, "", "", "", true}, - {"Importance: High", ImportanceHigh, "1", "1", "high", false}, - {"Importance: Urgent", ImportanceUrgent, "1", "1", "urgent", false}, - {"Importance: Unknown", 9, "", "", "", true}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetImportance(tt.imp) - hi, ok := m.genHeader[HeaderImportance] - if (!ok || len(hi) <= 0) && !tt.sf { - t.Errorf("SetImportance() method failed. Generic header for Importance is empty") - } - hp, ok := m.genHeader[HeaderPriority] - if (!ok || len(hp) <= 0) && !tt.sf { - t.Errorf("SetImportance() method failed. Generic header for Priority is empty") - } - hx, ok := m.genHeader[HeaderXPriority] - if (!ok || len(hx) <= 0) && !tt.sf { - t.Errorf("SetImportance() method failed. Generic header for X-Priority is empty") - } - hm, ok := m.genHeader[HeaderXMSMailPriority] - if (!ok || len(hm) <= 0) && !tt.sf { - t.Errorf("SetImportance() method failed. Generic header for X-MS-XPriority is empty") - } - if !tt.sf { - if hi[0] != tt.want { - t.Errorf("SetImportance() method failed. Expected Imporance: %s, got: %s", tt.want, hi[0]) - } - if hp[0] != tt.wantns { - t.Errorf("SetImportance() method failed. Expected Priority: %s, got: %s", tt.want, hp[0]) - } - if hx[0] != tt.xprio { - t.Errorf("SetImportance() method failed. Expected X-Priority: %s, got: %s", tt.want, hx[0]) - } - if hm[0] != tt.wantns { - t.Errorf("SetImportance() method failed. Expected X-MS-Priority: %s, got: %s", tt.wantns, hm[0]) - } - } - m.genHeader = nil - m.genHeader = make(map[Header][]string) - }) - } -} - -// TestMsg_SetOrganization tests the Msg.SetOrganization method -func TestMsg_SetOrganization(t *testing.T) { - tests := []struct { - name string - org string - }{ - {"Org: testcorp", "testcorp"}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetOrganization(tt.org) - o, ok := m.genHeader[HeaderOrganization] - if !ok || len(o) <= 0 { - t.Errorf("SetOrganization() method failed. Generic header for Organization is empty") - return - } - if o[0] != tt.org { - t.Errorf("SetOrganization() method failed. Expected: %s, got: %s", tt.org, o[0]) - } - }) - } -} - -// TestMsg_SetUserAgent tests the Msg.SetUserAgent method -func TestMsg_SetUserAgent(t *testing.T) { - tests := []struct { - name string - ua string - }{ - {"UA: Testmail 1.0", "Testmailer 1.0"}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetUserAgent(tt.ua) - xm, ok := m.genHeader[HeaderXMailer] - if !ok || len(xm) <= 0 { - t.Errorf("SetUserAgent() method failed. Generic header for X-Mailer is empty") - return - } - ua, ok := m.genHeader[HeaderUserAgent] - if !ok || len(ua) <= 0 { - t.Errorf("SetUserAgent() method failed. Generic header for UserAgent is empty") - return - } - if xm[0] != tt.ua { - t.Errorf("SetUserAgent() method failed. Expected X-Mailer: %s, got: %s", tt.ua, xm[0]) - } - if ua[0] != tt.ua { - t.Errorf("SetUserAgent() method failed. Expected User-Agent: %s, got: %s", tt.ua, ua[0]) - } - }) - } -} - -// TestMsg_RequestMDN tests the different RequestMDN* related methods of Msg -func TestMsg_RequestMDN(t *testing.T) { - n := "Toni Tester" - n2 := "Melanie Tester" - v := "toni.tester@example.com" - v2 := "melanie.tester@example.com" - iv := "testertest.tld" - vl := []string{v, v2} - m := NewMsg() - - // Single valid address - if err := m.RequestMDNTo(v); err != nil { - t.Errorf("RequestMDNTo with a single valid address failed: %s", err) - } - if val := m.genHeader[HeaderDispositionNotificationTo]; len(val) > 1 { - if val[0] != fmt.Sprintf("<%s>", v) { - t.Errorf("RequestMDNTo with a single valid address failed. Expected: %s, got: %s", v, - val[0]) - } - } - m.Reset() - - // Multiples valid addresses - if err := m.RequestMDNTo(vl...); err != nil { - t.Errorf("RequestMDNTo with a multiple valid address failed: %s", err) - } - if val := m.genHeader[HeaderDispositionNotificationTo]; len(val) > 0 { - if val[0] != fmt.Sprintf("<%s>", v) { - t.Errorf("RequestMDNTo with a multiple valid addresses failed. Expected 0: %s, got 0: %s", v, - val[0]) - } - } - if val := m.genHeader[HeaderDispositionNotificationTo]; len(val) > 1 { - if val[1] != fmt.Sprintf("<%s>", v2) { - t.Errorf("RequestMDNTo with a multiple valid addresses failed. Expected 1: %s, got 1: %s", v2, - val[1]) - } - } - m.Reset() - - // Invalid address - if err := m.RequestMDNTo(iv); err == nil { - t.Errorf("RequestMDNTo with an invalid address was supposed to failed, but didn't") - } - m.Reset() - - // Single valid addresses + AddTo - if err := m.RequestMDNTo(v); err != nil { - t.Errorf("RequestMDNTo with a single valid address failed: %s", err) - } - if err := m.RequestMDNAddTo(v2); err != nil { - t.Errorf("RequestMDNAddTo with a valid address failed: %s", err) - } - if val := m.genHeader[HeaderDispositionNotificationTo]; len(val) > 1 { - if val[1] != fmt.Sprintf("<%s>", v2) { - t.Errorf("RequestMDNTo with a multiple valid addresses failed. Expected 1: %s, got 1: %s", v2, - val[1]) - } - } - m.Reset() - - // Single valid address formated + AddToFromat - if err := m.RequestMDNToFormat(n, v); err != nil { - t.Errorf("RequestMDNToFormat with a single valid address failed: %s", err) - } - if val := m.genHeader[HeaderDispositionNotificationTo]; len(val) > 0 { - if val[0] != fmt.Sprintf(`"%s" <%s>`, n, v) { - t.Errorf(`RequestMDNToFormat with a single valid address failed. Expected: "%s" <%s>, got: %s`, n, v, - val[0]) - } - } - if err := m.RequestMDNAddToFormat(n2, v2); err != nil { - t.Errorf("RequestMDNAddToFormat with a valid address failed: %s", err) - } - if val := m.genHeader[HeaderDispositionNotificationTo]; len(val) > 1 { - if val[1] != fmt.Sprintf(`"%s" <%s>`, n2, v2) { - t.Errorf(`RequestMDNAddToFormat with a single valid address failed. Expected: "%s" <%s>, got: %s`, n2, v2, - val[1]) - } - } - m.Reset() - - // Invalid formated address - if err := m.RequestMDNToFormat(n, iv); err == nil { - t.Errorf("RequestMDNToFormat with an invalid address was supposed to failed, but didn't") - } - - // Invalid address AddTo + AddToFormat - if err := m.RequestMDNAddTo(iv); err == nil { - t.Errorf("RequestMDNAddTo with an invalid address was supposed to failed, but didn't") - } - if err := m.RequestMDNAddToFormat(n, iv); err == nil { - t.Errorf("RequestMDNAddToFormat with an invalid address was supposed to failed, but didn't") - } -} - -// TestMsg_SetBodyString tests the Msg.SetBodyString method -func TestMsg_SetBodyString(t *testing.T) { - tests := []struct { - name string - ct ContentType - value string - want string - sf bool - }{ - {"Body: test", TypeTextPlain, "test", "test", false}, - { - "Body: with Umlauts", TypeTextHTML, "üäöß", - "üäöß", false, - }, - {"Body: with emoji", TypeTextPlain, "📧", "📧", false}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetBodyString(tt.ct, tt.value) - if len(m.parts) != 1 { - t.Errorf("SetBodyString() failed: no mail parts found") - } - part := m.parts[0] - res := bytes.Buffer{} - if _, err := part.writeFunc(&res); err != nil && !tt.sf { - t.Errorf("WriteFunc of part failed: %s", err) - } - if res.String() != tt.want { - t.Errorf("SetBodyString() failed. Expecteding: %s, got: %s", tt.want, res.String()) - } - }) - } -} - -// TestMsg_AddAlternativeString tests the Msg.AddAlternativeString method -func TestMsg_AddAlternativeString(t *testing.T) { - tests := []struct { - name string - value string - want string - sf bool - }{ - {"Body: test", "test", "test", false}, - {"Body: with Umlauts", "üäöß", "üäöß", false}, - {"Body: with emoji", "📧", "📧", false}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.SetBodyString(TypeTextPlain, tt.value) - if len(m.parts) != 1 { - t.Errorf("AddAlternativeString() => SetBodyString() failed: no mail parts found") - } - m.AddAlternativeString(TypeTextHTML, tt.value) - if len(m.parts) != 2 { - t.Errorf("AddAlternativeString() failed: no alternative mail parts found") - } - apart := m.parts[1] - res := bytes.Buffer{} - if _, err := apart.writeFunc(&res); err != nil && !tt.sf { - t.Errorf("WriteFunc of part failed: %s", err) - } - if res.String() != tt.want { - t.Errorf("AddAlternativeString() failed. Expecteding: %s, got: %s", tt.want, res.String()) - } - }) - } -} - -// TestMsg_AttachFile tests the Msg.AttachFile and the WithFilename FileOption method -func TestMsg_AttachFile(t *testing.T) { - tests := []struct { - name string - file string - fn string - sf bool - }{ - {"File: README.md", "README.md", "README.md", false}, - {"File: doc.go", "doc.go", "foo.go", false}, - {"File: nonexisting", "", "invalid.file", true}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.AttachFile(tt.file, WithFileName(tt.fn), nil) - if len(m.attachments) != 1 && !tt.sf { - t.Errorf("AttachFile() failed. Number of attachments expected: %d, got: %d", 1, - len(m.attachments)) - return - } - if !tt.sf { - file := m.attachments[0] - if file == nil { - t.Errorf("AttachFile() failed. Attachment file pointer is nil") - return - } - if file.Name != tt.fn { - t.Errorf("AttachFile() failed. Filename of attachment expected: %s, got: %s", tt.fn, - file.Name) - } - buf := bytes.Buffer{} - if _, err := file.Writer(&buf); err != nil { - t.Errorf("failed to execute WriterFunc: %s", err) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_GetAttachments tests the Msg.GetAttachments method -func TestMsg_GetAttachments(t *testing.T) { - tests := []struct { - name string - files []string - }{ - {"File: README.md", []string{"README.md"}}, - {"File: doc.go", []string{"doc.go"}}, - {"File: README.md and doc.go", []string{"README.md", "doc.go"}}, - {"File: nonexisting", nil}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for _, f := range tt.files { - m.AttachFile(f, WithFileName(f), nil) - } - if len(m.attachments) != len(tt.files) { - t.Errorf("AttachFile() failed. Number of attachments expected: %d, got: %d", len(tt.files), - len(m.attachments)) - return - } - ff := m.GetAttachments() - if len(m.attachments) != len(ff) { - t.Errorf("GetAttachments() failed. Number of attachments expected: %d, got: %d", len(m.attachments), - len(ff)) - return - } - var fn []string - for _, f := range ff { - fn = append(fn, f.Name) - } - sort.Strings(fn) - sort.Strings(tt.files) - for i, f := range tt.files { - if f != fn[i] { - t.Errorf("GetAttachments() failed. Attachment name expected: %s, got: %s", f, - fn[i]) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_SetAttachments tests the Msg.GetAttachments method -func TestMsg_SetAttachments(t *testing.T) { - tests := []struct { - name string - attachments []string - files []string - }{ - {"File: replace README.md with doc.go", []string{"README.md"}, []string{"doc.go"}}, - {"File: add README.md with doc.go ", []string{"doc.go"}, []string{"README.md", "doc.go"}}, - {"File: remove README.md and doc.go", []string{"README.md", "doc.go"}, nil}, - {"File: add README.md and doc.go", nil, []string{"README.md", "doc.go"}}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sort.Strings(tt.attachments) - sort.Strings(tt.files) - for _, a := range tt.attachments { - m.AttachFile(a, WithFileName(a), nil) - } - if len(m.attachments) != len(tt.attachments) { - t.Errorf("AttachFile() failed. Number of attachments expected: %d, got: %d", len(tt.files), - len(m.attachments)) - return - } - var files []*File - for _, f := range tt.files { - files = append(files, &File{Name: f}) - } - m.SetAttachments(files) - if len(m.attachments) != len(files) { - t.Errorf("SetAttachements() failed. Number of attachments expected: %d, got: %d", len(files), - len(m.attachments)) - return - } - for i, f := range tt.files { - if f != m.attachments[i].Name { - t.Errorf("SetAttachments() failed. Attachment name expected: %s, got: %s", f, - m.attachments[i].Name) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_UnsetAllAttachments tests the Msg.UnsetAllAttachments method -func TestMsg_UnsetAllAttachments(t *testing.T) { - tests := []struct { - name string - attachments []string - }{ - {"File: one file", []string{"README.md"}}, - {"File: two files", []string{"README.md", "doc.go"}}, - {"File: nil", nil}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var files []*File - for _, f := range tt.attachments { - files = append(files, &File{Name: f}) - } - m.SetAttachments(files) - - if len(m.attachments) != len(files) { - t.Errorf("SetAttachements() failed. Number of attachments expected: %d, got: %d", len(files), - len(m.attachments)) - return - } - m.UnsetAllAttachments() - if m.attachments != nil { - t.Errorf("UnsetAllAttachments() failed. The attachments file's pointer is not nil") - return - } - m.Reset() - }) - } -} - -// TestMsg_GetEmbeds tests the Msg.GetEmbeds method -func TestMsg_GetEmbeds(t *testing.T) { - tests := []struct { - name string - files []string - }{ - {"File: README.md", []string{"README.md"}}, - {"File: doc.go", []string{"doc.go"}}, - {"File: README.md and doc.go", []string{"README.md", "doc.go"}}, - {"File: nonexisting", nil}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for _, f := range tt.files { - m.EmbedFile(f, WithFileName(f), nil) - } - if len(m.embeds) != len(tt.files) { - t.Errorf("EmbedFile() failed. Number of embedded files expected: %d, got: %d", len(tt.files), - len(m.embeds)) - return - } - ff := m.GetEmbeds() - if len(m.embeds) != len(ff) { - t.Errorf("GetEmbeds() failed. Number of embedded files expected: %d, got: %d", len(m.embeds), - len(ff)) - return - } - var fn []string - for _, f := range ff { - fn = append(fn, f.Name) - } - sort.Strings(fn) - sort.Strings(tt.files) - for i, f := range tt.files { - if f != fn[i] { - t.Errorf("GetEmbeds() failed. Embedded file name expected: %s, got: %s", f, - fn[i]) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_SetEmbeds tests the Msg.GetEmbeds method -func TestMsg_SetEmbeds(t *testing.T) { - tests := []struct { - name string - embeds []string - files []string - }{ - {"File: replace README.md with doc.go", []string{"README.md"}, []string{"doc.go"}}, - {"File: add README.md with doc.go ", []string{"doc.go"}, []string{"README.md", "doc.go"}}, - {"File: remove README.md and doc.go", []string{"README.md", "doc.go"}, nil}, - {"File: add README.md and doc.go", nil, []string{"README.md", "doc.go"}}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sort.Strings(tt.embeds) - sort.Strings(tt.files) - for _, a := range tt.embeds { - m.EmbedFile(a, WithFileName(a), nil) - } - if len(m.embeds) != len(tt.embeds) { - t.Errorf("EmbedFile() failed. Number of embedded files expected: %d, got: %d", len(tt.files), - len(m.embeds)) - return - } - var files []*File - for _, f := range tt.files { - files = append(files, &File{Name: f}) - } - m.SetEmbeds(files) - if len(m.embeds) != len(files) { - t.Errorf("SetEmbeds() failed. Number of embedded files expected: %d, got: %d", len(files), - len(m.embeds)) - return - } - for i, f := range tt.files { - if f != m.embeds[i].Name { - t.Errorf("SetEmbeds() failed. Embedded file name expected: %s, got: %s", f, - m.embeds[i].Name) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_UnsetAllEmbeds tests the Msg.TestMsg_UnsetAllEmbeds method -func TestMsg_UnsetAllEmbeds(t *testing.T) { - tests := []struct { - name string - embeds []string - }{ - {"File: one file", []string{"README.md"}}, - {"File: two files", []string{"README.md", "doc.go"}}, - {"File: nil", nil}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var files []*File - for _, f := range tt.embeds { - files = append(files, &File{Name: f}) - } - m.SetEmbeds(files) - if len(m.embeds) != len(files) { - t.Errorf("SetEmbeds() failed. Number of embedded files expected: %d, got: %d", len(files), - len(m.embeds)) - return - } - m.UnsetAllEmbeds() - if m.embeds != nil { - t.Errorf("UnsetAllEmbeds() failed. The embeds file's point is not nil") - return - } - m.Reset() - }) - } -} - -// TestMsg_UnsetAllParts tests the Msg.TestMsg_UnsetAllParts method -func TestMsg_UnsetAllParts(t *testing.T) { - tests := []struct { - name string - attachments []string - embeds []string - }{ - {"File: both is exist", []string{"README.md"}, []string{"doc.go"}}, - {"File: both is nil", nil, nil}, - {"File: attachment exist, embed nil", []string{"README.md"}, nil}, - {"File: attachment nil, embed exist", nil, []string{"README.md"}}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var attachments []*File - for _, f := range tt.attachments { - attachments = append(attachments, &File{Name: f}) - } - m.SetAttachments(attachments) - if len(m.attachments) != len(attachments) { - t.Errorf("SetAttachements() failed. Number of attachments files expected: %d, got: %d", - len(attachments), len(m.attachments)) - return - } - var embeds []*File - for _, f := range tt.embeds { - embeds = append(embeds, &File{Name: f}) - } - m.SetEmbeds(embeds) - if len(m.embeds) != len(embeds) { - t.Errorf("SetEmbeds() failed. Number of embedded files expected: %d, got: %d", len(embeds), - len(m.embeds)) - return - } - m.UnsetAllParts() - if m.attachments != nil { - t.Errorf("UnsetAllParts() failed. The attachments file's point is not nil") - return - } - if m.embeds != nil { - t.Errorf("UnsetAllParts() failed. The embeds file's point is not nil") - return - } - m.Reset() - }) - } -} - -// TestMsg_AttachFromEmbedFS tests the Msg.AttachFromEmbedFS and the WithFilename FileOption method -func TestMsg_AttachFromEmbedFS(t *testing.T) { - tests := []struct { - name string - file string - fn string - sf bool - }{ - {"File: README.md", "README.md", "README.md", false}, - {"File: nonexisting", "", "invalid.file", true}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := m.AttachFromEmbedFS(tt.file, &efs, WithFileName(tt.fn)); err != nil && !tt.sf { - t.Errorf("AttachFromEmbedFS() failed: %s", err) - return - } - if len(m.attachments) != 1 && !tt.sf { - t.Errorf("AttachFile() failed. Number of attachments expected: %d, got: %d", 1, - len(m.attachments)) - return - } - if !tt.sf { - file := m.attachments[0] - if file == nil { - t.Errorf("AttachFile() failed. Attachment file pointer is nil") - return - } - if file.Name != tt.fn { - t.Errorf("AttachFile() failed. Filename of attachment expected: %s, got: %s", tt.fn, - file.Name) - } - buf := bytes.Buffer{} - if _, err := file.Writer(&buf); err != nil { - t.Errorf("failed to execute WriterFunc: %s", err) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_AttachFileBrokenFunc tests WriterFunc of the Msg.AttachFile method -func TestMsg_AttachFileBrokenFunc(t *testing.T) { - m := NewMsg() - m.AttachFile("README.md") - if len(m.attachments) != 1 { - t.Errorf("AttachFile() failed. Number of attachments expected: %d, got: %d", 1, - len(m.attachments)) - return - } - file := m.attachments[0] - if file == nil { - t.Errorf("AttachFile() failed. Attachment file pointer is nil") - return - } - file.Writer = func(io.Writer) (int64, error) { - return 0, fmt.Errorf("failing intentionally") - } - buf := bytes.Buffer{} - if _, err := file.Writer(&buf); err == nil { - t.Errorf("execute WriterFunc did not fail, but was expected to fail") - } -} - -// TestMsg_AttachReader tests the Msg.AttachReader method -func TestMsg_AttachReader(t *testing.T) { - m := NewMsg() - ts := "This is a test string" - rbuf := bytes.Buffer{} - rbuf.WriteString(ts) - r := bufio.NewReader(&rbuf) - if err := m.AttachReader("testfile.txt", r); err != nil { - t.Errorf("AttachReader() failed. Expected no error, got: %s", err.Error()) - return - } - if len(m.attachments) != 1 { - t.Errorf("AttachReader() failed. Number of attachments expected: %d, got: %d", 1, - len(m.attachments)) - return - } - file := m.attachments[0] - if file == nil { - t.Errorf("AttachReader() failed. Attachment file pointer is nil") - return - } - if file.Name != "testfile.txt" { - t.Errorf("AttachReader() failed. Expected file name: %s, got: %s", "testfile.txt", - file.Name) - } - wbuf := bytes.Buffer{} - if _, err := file.Writer(&wbuf); err != nil { - t.Errorf("execute WriterFunc failed: %s", err) - } - if wbuf.String() != ts { - t.Errorf("AttachReader() failed. Expected string: %q, got: %q", ts, wbuf.String()) - } -} - -// TestMsg_EmbedFile tests the Msg.EmbedFile and the WithFilename FileOption method -func TestMsg_EmbedFile(t *testing.T) { - tests := []struct { - name string - file string - fn string - sf bool - }{ - {"File: README.md", "README.md", "README.md", false}, - {"File: doc.go", "doc.go", "foo.go", false}, - {"File: nonexisting", "", "invalid.file", true}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m.EmbedFile(tt.file, WithFileName(tt.fn), nil) - if len(m.embeds) != 1 && !tt.sf { - t.Errorf("EmbedFile() failed. Number of embeds expected: %d, got: %d", 1, - len(m.embeds)) - return - } - if !tt.sf { - file := m.embeds[0] - if file == nil { - t.Errorf("EmbedFile() failed. Embedded file pointer is nil") - return - } - if file.Name != tt.fn { - t.Errorf("EmbedFile() failed. Filename of embeds expected: %s, got: %s", tt.fn, - file.Name) - } - buf := bytes.Buffer{} - if _, err := file.Writer(&buf); err != nil { - t.Errorf("failed to execute WriterFunc: %s", err) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_EmbedFromEmbedFS tests the Msg.EmbedFromEmbedFS and the WithFilename FileOption method -func TestMsg_EmbedFromEmbedFS(t *testing.T) { - tests := []struct { - name string - file string - fn string - sf bool - }{ - {"File: README.md", "README.md", "README.md", false}, - {"File: nonexisting", "", "invalid.file", true}, - } - m := NewMsg() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := m.EmbedFromEmbedFS(tt.file, &efs, WithFileName(tt.fn)); err != nil && !tt.sf { - t.Errorf("EmbedFromEmbedFS() failed: %s", err) - return - } - if len(m.embeds) != 1 && !tt.sf { - t.Errorf("EmbedFile() failed. Number of embeds expected: %d, got: %d", 1, - len(m.embeds)) - return - } - if !tt.sf { - file := m.embeds[0] - if file == nil { - t.Errorf("EmbedFile() failed. Embedded file pointer is nil") - return - } - if file.Name != tt.fn { - t.Errorf("EmbedFile() failed. Filename of embeds expected: %s, got: %s", tt.fn, - file.Name) - } - buf := bytes.Buffer{} - if _, err := file.Writer(&buf); err != nil { - t.Errorf("failed to execute WriterFunc: %s", err) - return - } - } - m.Reset() - }) - } -} - -// TestMsg_EmbedFileBrokenFunc tests WriterFunc of the Msg.EmbedFile method -func TestMsg_EmbedFileBrokenFunc(t *testing.T) { - m := NewMsg() - m.EmbedFile("README.md") - if len(m.embeds) != 1 { - t.Errorf("EmbedFile() failed. Number of embeds expected: %d, got: %d", 1, - len(m.embeds)) - return - } - file := m.embeds[0] - if file == nil { - t.Errorf("EmbedFile() failed. Embedded file pointer is nil") - return - } - file.Writer = func(io.Writer) (int64, error) { - return 0, fmt.Errorf("failing intentionally") - } - buf := bytes.Buffer{} - if _, err := file.Writer(&buf); err == nil { - t.Errorf("execute WriterFunc did not fail, but was expected to fail") - } -} - -// TestMsg_EmbedReader tests the Msg.EmbedReader method -func TestMsg_EmbedReader(t *testing.T) { - m := NewMsg() - ts := "This is a test string" - rbuf := bytes.Buffer{} - rbuf.WriteString(ts) - r := bufio.NewReader(&rbuf) - if err := m.EmbedReader("testfile.txt", r); err != nil { - t.Errorf("EmbedReader() failed. Expected no error, got: %s", err.Error()) - return - } - if len(m.embeds) != 1 { - t.Errorf("EmbedReader() failed. Number of embeds expected: %d, got: %d", 1, - len(m.embeds)) - return - } - file := m.embeds[0] - if file == nil { - t.Errorf("EmbedReader() failed. Embedded file pointer is nil") - return - } - if file.Name != "testfile.txt" { - t.Errorf("EmbedReader() failed. Expected file name: %s, got: %s", "testfile.txt", - file.Name) - } - wbuf := bytes.Buffer{} - if _, err := file.Writer(&wbuf); err != nil { - t.Errorf("execute WriterFunc failed: %s", err) - } - if wbuf.String() != ts { - t.Errorf("EmbedReader() failed. Expected string: %q, got: %q", ts, wbuf.String()) - } -} - -// TestMsg_hasAlt tests the hasAlt() method of the Msg -func TestMsg_hasAlt(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "Plain") - m.AddAlternativeString(TypeTextHTML, "HTML") - if !m.hasAlt() { - t.Errorf("mail has alternative parts but hasAlt() returned true") - } -} - -// TestMsg_hasRelated tests the hasRelated() method of the Msg -func TestMsg_hasRelated(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "Plain") - m.EmbedFile("README.md") - if !m.hasRelated() { - t.Errorf("mail has related parts but hasRelated() returned true") - } -} - -// TestMsg_hasMixed tests the hasMixed() method of the Msg -func TestMsg_hasMixed(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "Plain") - m.AttachFile("README.md") - if !m.hasMixed() { - t.Errorf("mail has mixed parts but hasMixed() returned true") - } -} - -// TestMsg_WriteTo tests the WriteTo() method of the Msg -func TestMsg_WriteTo(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "Plain") - wbuf := bytes.Buffer{} - n, err := m.WriteTo(&wbuf) - if err != nil { - t.Errorf("WriteTo() failed: %s", err) - return - } - if n != int64(wbuf.Len()) { - t.Errorf("WriteTo() failed: expected written byte length: %d, got: %d", n, wbuf.Len()) - } -} - -// TestMsg_WriteToSkipMiddleware tests the WriteTo() method of the Msg -func TestMsg_WriteToSkipMiddleware(t *testing.T) { - m := NewMsg(WithMiddleware(encodeMiddleware{}), WithMiddleware(uppercaseMiddleware{})) - m.Subject("This is a test") - m.SetBodyString(TypeTextPlain, "Plain") - wbuf := bytes.Buffer{} - n, err := m.WriteToSkipMiddleware(&wbuf, "uppercase") - if err != nil { - t.Errorf("WriteToSkipMiddleware() failed: %s", err) - return - } - if n != int64(wbuf.Len()) { - t.Errorf("WriteToSkipMiddleware() failed: expected written byte length: %d, got: %d", n, wbuf.Len()) - } - if !strings.Contains(wbuf.String(), "Subject: This is @ test") { - t.Errorf("WriteToSkipMiddleware failed. Unable to find encoded subject") - } - - wbuf2 := bytes.Buffer{} - n, err = m.WriteTo(&wbuf2) - if err != nil { - t.Errorf("WriteTo() failed: %s", err) - return - } - if n != int64(wbuf2.Len()) { - t.Errorf("WriteTo() failed: expected written byte length: %d, got: %d", n, wbuf2.Len()) - } - if !strings.Contains(wbuf2.String(), "Subject: THIS IS @ TEST") { - t.Errorf("WriteToSkipMiddleware failed. Unable to find encoded and upperchase subject") - } -} - -// TestMsg_WriteTo_fails tests the WriteTo() method of the Msg but with a failing body writer function -func TestMsg_WriteTo_fails(t *testing.T) { - m := NewMsg() - m.SetBodyWriter(TypeTextPlain, func(io.Writer) (int64, error) { - return 0, errors.New("failed") - }) - _, err := m.WriteTo(io.Discard) - if err == nil { - t.Errorf("WriteTo() with failing BodyWriter function was supposed to fail, but didn't") - return - } - - // NoEncoding handles the errors separately - m = NewMsg(WithEncoding(NoEncoding)) - m.SetBodyWriter(TypeTextPlain, func(io.Writer) (int64, error) { - return 0, errors.New("failed") - }) - _, err = m.WriteTo(io.Discard) - if err == nil { - t.Errorf("WriteTo() (no encoding) with failing BodyWriter function was supposed to fail, but didn't") - return - } -} - -// TestMsg_Write tests the Write() method of the Msg -func TestMsg_Write(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "Plain") - wbuf := bytes.Buffer{} - n, err := m.Write(&wbuf) - if err != nil { - t.Errorf("WriteTo() failed: %s", err) - return - } - if n != int64(wbuf.Len()) { - t.Errorf("WriteTo() failed: expected written byte length: %d, got: %d", n, wbuf.Len()) - } -} - -// TestMsg_WriteWithLongHeader tests the WriteTo() method of the Msg with a long header -func TestMsg_WriteWithLongHeader(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "Plain") - m.SetGenHeader(HeaderContentLang, "de", "en", "fr", "es", "xxxx", "yyyy", "de", "en", "fr", - "es", "xxxx", "yyyy", "de", "en", "fr", "es", "xxxx", "yyyy", "de", "en", "fr") - m.SetGenHeader(HeaderContentID, "XXXXXXXXXXXXXXX XXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXX", - "XXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXX") - wbuf := bytes.Buffer{} - n, err := m.WriteTo(&wbuf) - if err != nil { - t.Errorf("WriteTo() failed: %s", err) - return - } - if n != int64(wbuf.Len()) { - t.Errorf("WriteTo() failed: expected written byte length: %d, got: %d", n, wbuf.Len()) - } -} - -// TestMsg_WriteDiffEncoding tests the WriteTo() method of the Msg with different Encoding -func TestMsg_WriteDiffEncoding(t *testing.T) { - tests := []struct { - name string - ct ContentType - en Encoding - alt bool - wa bool - we bool - }{ - {"Plain/QP/NoAlt/NoAttach/NoEmbed", TypeTextPlain, EncodingQP, false, false, false}, - {"Plain/B64/NoAlt/NoAttach/NoEmbed", TypeTextPlain, EncodingB64, false, false, false}, - {"Plain/No/NoAlt/NoAttach/NoEmbed", TypeTextPlain, NoEncoding, false, false, false}, - {"HTML/QP/NoAlt/NoAttach/NoEmbed", TypeTextHTML, EncodingQP, false, false, false}, - {"HTML/B64/NoAlt/NoAttach/NoEmbed", TypeTextHTML, EncodingB64, false, false, false}, - {"HTML/No/NoAlt/NoAttach/NoEmbed", TypeTextHTML, NoEncoding, false, false, false}, - {"Plain/QP/HTML/NoAttach/NoEmbed", TypeTextPlain, EncodingQP, true, false, false}, - {"Plain/B64/HTML/NoAttach/NoEmbed", TypeTextPlain, EncodingB64, true, false, false}, - {"Plain/No/HTML/NoAttach/NoEmbed", TypeTextPlain, NoEncoding, true, false, false}, - {"Plain/QP/NoAlt/Attach/NoEmbed", TypeTextPlain, EncodingQP, false, true, false}, - {"Plain/B64/NoAlt/Attach/NoEmbed", TypeTextPlain, EncodingB64, false, true, false}, - {"Plain/No/NoAlt/Attach/NoEmbed", TypeTextPlain, NoEncoding, false, true, false}, - {"Plain/QP/NoAlt/NoAttach/Embed", TypeTextPlain, EncodingQP, false, false, true}, - {"Plain/B64/NoAlt/NoAttach/Embed", TypeTextPlain, EncodingB64, false, false, true}, - {"Plain/No/NoAlt/NoAttach/Embed", TypeTextPlain, NoEncoding, false, false, true}, - {"Plain/QP/HTML/Attach/Embed", TypeTextPlain, EncodingQP, true, true, true}, - {"Plain/B64/HTML/Attach/Embed", TypeTextPlain, EncodingB64, true, true, true}, - {"Plain/No/HTML/Attach/Embed", TypeTextPlain, NoEncoding, true, true, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMsg(WithEncoding(tt.en)) - m.SetBodyString(tt.ct, tt.name) - if tt.alt { - m.AddAlternativeString(TypeTextHTML, fmt.Sprintf("

%s

", tt.name)) - } - if tt.wa { - m.AttachFile("README.md") - } - if tt.we { - m.EmbedFile("README.md") - } - wbuf := bytes.Buffer{} - n, err := m.WriteTo(&wbuf) - if err != nil { - t.Errorf("WriteTo() failed: %s", err) - return - } - if n != int64(wbuf.Len()) { - t.Errorf("WriteTo() failed: expected written byte length: %d, got: %d", n, wbuf.Len()) - } - wbuf.Reset() - }) - } -} - -// TestMsg_appendFile tests the appendFile() method of the Msg -func TestMsg_appendFile(t *testing.T) { - m := NewMsg() - var fl []*File - f := &File{ - Name: "file.txt", - } - fl = m.appendFile(fl, f, nil) - if len(fl) != 1 { - t.Errorf("appendFile() failed. Expected length: %d, got: %d", 1, len(fl)) - } - fl = m.appendFile(fl, f, nil) - if len(fl) != 2 { - t.Errorf("appendFile() failed. Expected length: %d, got: %d", 2, len(fl)) - } -} - -// TestMsg_multipleWrites tests multiple executions of WriteTo on the Msg -func TestMsg_multipleWrites(t *testing.T) { - ts := "XXX_UNIQUE_STRING_XXX" - wbuf := bytes.Buffer{} - m := NewMsg() - m.SetBodyString(TypeTextPlain, ts) - - // First WriteTo() - _, err := m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), ts) { - t.Errorf("first WriteTo() body does not contain unique string: %s", ts) - } - - // Second WriteTo() - wbuf.Reset() - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), ts) { - t.Errorf("second WriteTo() body does not contain unique string: %s", ts) - } -} - -// TestMsg_NewReader tests the Msg.NewReader method -func TestMsg_NewReader(t *testing.T) { - m := NewMsg() - m.SetBodyString(TypeTextPlain, "TEST123") - mr := m.NewReader() - if mr == nil { - t.Errorf("NewReader failed: Reader is nil") - } - if mr.Error() != nil { - t.Errorf("NewReader failed: %s", mr.Error()) - } -} - -// TestMsg_NewReader_ioCopy tests the Msg.NewReader method using io.Copy -func TestMsg_NewReader_ioCopy(t *testing.T) { - wbuf1 := bytes.Buffer{} - wbuf2 := bytes.Buffer{} - m := NewMsg() - m.SetBodyString(TypeTextPlain, "TEST123") - mr := m.NewReader() - if mr == nil { - t.Errorf("NewReader failed: Reader is nil") - } - - // First we use WriteTo to have something to compare to - _, err := m.WriteTo(&wbuf1) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - - // Then we write to wbuf2 via io.Copy - n, err := io.Copy(&wbuf2, mr) - if err != nil { - t.Errorf("failed to use io.Copy on Reader: %s", err) - } - if n != int64(wbuf1.Len()) { - t.Errorf("message length of WriteTo and io.Copy differ. Expected: %d, got: %d", wbuf1.Len(), n) - } - if wbuf1.String() != wbuf2.String() { - t.Errorf("message content of WriteTo and io.Copy differ") - } -} - -// TestMsg_UpdateReader tests the Msg.UpdateReader method -func TestMsg_UpdateReader(t *testing.T) { - m := NewMsg() - m.Subject("Subject-Run 1") - mr := m.NewReader() - if mr == nil { - t.Errorf("NewReader failed: Reader is nil") - } - wbuf1 := bytes.Buffer{} - _, err := io.Copy(&wbuf1, mr) - if err != nil { - t.Errorf("io.Copy on Reader failed: %s", err) - } - if !strings.Contains(wbuf1.String(), "Subject: Subject-Run 1") { - t.Errorf("io.Copy on Reader failed. Expected to find %q but string in Subject was not found", - "Subject-Run 1") - } - - m.Subject("Subject-Run 2") - m.UpdateReader(mr) - wbuf2 := bytes.Buffer{} - _, err = io.Copy(&wbuf2, mr) - if err != nil { - t.Errorf("2nd io.Copy on Reader failed: %s", err) - } - if !strings.Contains(wbuf2.String(), "Subject: Subject-Run 2") { - t.Errorf("io.Copy on Reader failed. Expected to find %q but string in Subject was not found", - "Subject-Run 2") - } -} - -// TestMsg_SetBodyTextTemplate tests the Msg.SetBodyTextTemplate method -func TestMsg_SetBodyTextTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - sf bool - }{ - {"normal text", "This is a {{.Placeholder}}", "TemplateTest", false}, - {"invalid tpl", "This is a {{ foo .Placeholder}}", "TemplateTest", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := ttpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - if err := m.SetBodyTextTemplate(tpl, data); err != nil && !tt.sf { - t.Errorf("failed to set template as body: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ph) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - m.Reset() - }) - } -} - -// TestMsg_SetBodyHTMLTemplate tests the Msg.SetBodyHTMLTemplate method -func TestMsg_SetBodyHTMLTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - ex string - sf bool - }{ - {"normal HTML", "

This is a {{.Placeholder}}

", "TemplateTest", "TemplateTest", false}, - { - "HTML with HTML", "

This is a {{.Placeholder}}

", "", - "<script>alert(1)</script>", false, - }, - {"invalid tpl", "

This is a {{ foo .Placeholder}}

", "TemplateTest", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := htpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - if err := m.SetBodyHTMLTemplate(tpl, data); err != nil && !tt.sf { - t.Errorf("failed to set template as body: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ex) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - m.Reset() - }) - } -} - -// TestMsg_AddAlternativeTextTemplate tests the Msg.AddAlternativeTextTemplate method -func TestMsg_AddAlternativeTextTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - sf bool - }{ - {"normal text", "This is a {{.Placeholder}}", "TemplateTest", false}, - {"invalid tpl", "This is a {{ foo .Placeholder}}", "TemplateTest", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := ttpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - m.SetBodyString(TypeTextHTML, "") - if err := m.AddAlternativeTextTemplate(tpl, data); err != nil && !tt.sf { - t.Errorf("failed to set template as alternative part: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ph) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - m.Reset() - }) - } -} - -// TestMsg_AddAlternativeHTMLTemplate tests the Msg.AddAlternativeHTMLTemplate method -func TestMsg_AddAlternativeHTMLTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - ex string - sf bool - }{ - {"normal HTML", "

This is a {{.Placeholder}}

", "TemplateTest", "TemplateTest", false}, - { - "HTML with HTML", "

This is a {{.Placeholder}}

", "", - "<script>alert(1)</script>", false, - }, - {"invalid tpl", "

This is a {{ foo .Placeholder}}

", "TemplateTest", "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := htpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - m.SetBodyString(TypeTextPlain, "") - if err := m.AddAlternativeHTMLTemplate(tpl, data); err != nil && !tt.sf { - t.Errorf("failed to set template as alternative part: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ex) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - m.Reset() - }) - } -} - -// TestMsg_AttachTextTemplate tests the Msg.AttachTextTemplate method -func TestMsg_AttachTextTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - ex string - ac int - sf bool - }{ - { - "normal text", "This is a {{.Placeholder}}", "TemplateTest", - "VGhpcyBpcyBhIFRlbXBsYXRlVGVzdA==", 1, false, - }, - {"invalid tpl", "This is a {{ foo .Placeholder}}", "TemplateTest", "", 0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := ttpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - m.SetBodyString(TypeTextPlain, "This is the body") - if err := m.AttachTextTemplate("attachment.txt", tpl, data); err != nil && !tt.sf { - t.Errorf("failed to attach template: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ex) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - if len(m.attachments) != tt.ac { - t.Errorf("wrong number of attachments. Expected: %d, got: %d", tt.ac, len(m.attachments)) - } - m.Reset() - }) - } -} - -// TestMsg_AttachHTMLTemplate tests the Msg.AttachHTMLTemplate method -func TestMsg_AttachHTMLTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - ex string - ac int - sf bool - }{ - { - "normal HTML", "

This is a {{.Placeholder}}

", "TemplateTest", - "PHA+VGhpcyBpcyBhIFRlbXBsYXRlVGVzdDwvcD4=", 1, false, - }, - { - "HTML with HTML", "

This is a {{.Placeholder}}

", "", - "PHA+VGhpcyBpcyBhICZsdDtzY3JpcHQmZ3Q7YWxlcnQoMSkmbHQ7L3NjcmlwdCZndDs8L3A+", 1, false, - }, - {"invalid tpl", "

This is a {{ foo .Placeholder}}

", "TemplateTest", "", 0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := htpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - m.SetBodyString(TypeTextPlain, "") - if err := m.AttachHTMLTemplate("attachment.html", tpl, data); err != nil && !tt.sf { - t.Errorf("failed to set template as alternative part: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ex) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - if len(m.attachments) != tt.ac { - t.Errorf("wrong number of attachments. Expected: %d, got: %d", tt.ac, len(m.attachments)) - } - m.Reset() - }) - } -} - -// TestMsg_EmbedTextTemplate tests the Msg.EmbedTextTemplate method -func TestMsg_EmbedTextTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - ex string - ec int - sf bool - }{ - { - "normal text", "This is a {{.Placeholder}}", "TemplateTest", - "VGhpcyBpcyBhIFRlbXBsYXRlVGVzdA==", 1, false, - }, - {"invalid tpl", "This is a {{ foo .Placeholder}}", "TemplateTest", "", 0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := ttpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - m.SetBodyString(TypeTextPlain, "This is the body") - if err := m.EmbedTextTemplate("attachment.txt", tpl, data); err != nil && !tt.sf { - t.Errorf("failed to attach template: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ex) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - if len(m.embeds) != tt.ec { - t.Errorf("wrong number of attachments. Expected: %d, got: %d", tt.ec, len(m.attachments)) - } - m.Reset() - }) - } -} - -// TestMsg_EmbedHTMLTemplate tests the Msg.EmbedHTMLTemplate method -func TestMsg_EmbedHTMLTemplate(t *testing.T) { - tests := []struct { - name string - tpl string - ph string - ex string - ec int - sf bool - }{ - { - "normal HTML", "

This is a {{.Placeholder}}

", "TemplateTest", - "PHA+VGhpcyBpcyBhIFRlbXBsYXRlVGVzdDwvcD4=", 1, false, - }, - { - "HTML with HTML", "

This is a {{.Placeholder}}

", "", - "PHA+VGhpcyBpcyBhICZsdDtzY3JpcHQmZ3Q7YWxlcnQoMSkmbHQ7L3NjcmlwdCZndDs8L3A+", 1, false, - }, - {"invalid tpl", "

This is a {{ foo .Placeholder}}

", "TemplateTest", "", 0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data := struct { - Placeholder string - }{Placeholder: tt.ph} - tpl, err := htpl.New("test").Parse(tt.tpl) - if err != nil && !tt.sf { - t.Errorf("failed to render template: %s", err) - return - } - m := NewMsg() - m.SetBodyString(TypeTextPlain, "") - if err := m.EmbedHTMLTemplate("attachment.html", tpl, data); err != nil && !tt.sf { - t.Errorf("failed to set template as alternative part: %s", err) - } - - wbuf := bytes.Buffer{} - _, err = m.WriteTo(&wbuf) - if err != nil { - t.Errorf("failed to write body to buffer: %s", err) - } - if !strings.Contains(wbuf.String(), tt.ex) && !tt.sf { - t.Errorf("SetBodyTextTemplate failed: Body does not contain the expected tpl placeholder: %s", tt.ph) - } - if len(m.embeds) != tt.ec { - t.Errorf("wrong number of attachments. Expected: %d, got: %d", tt.ec, len(m.attachments)) - } - m.Reset() - }) - } -} - -// TestMsg_WriteToTempFile will test the output to temporary files -func TestMsg_WriteToTempFile(t *testing.T) { - m := NewMsg() - _ = m.From("Toni Tester ") - _ = m.To("Ellenor Tester ") - m.SetBodyString(TypeTextPlain, "This is a test") - f, err := m.WriteToTempFile() - if err != nil { - t.Errorf("failed to write message to temporary output file: %s", err) - } - _ = os.Remove(f) -} - -// TestMsg_WriteToFile will test the output to a file -func TestMsg_WriteToFile(t *testing.T) { - f, err := os.CreateTemp("", "go-mail-test_*.eml") - if err != nil { - t.Errorf("failed to create temporary output file: %s", err) - } - defer func() { - _ = f.Close() - _ = os.Remove(f.Name()) - }() - - m := NewMsg() - _ = m.From("Toni Tester ") - _ = m.To("Ellenor Tester ") - m.SetBodyString(TypeTextPlain, "This is a test") - if err := m.WriteToFile(f.Name()); err != nil { - t.Errorf("failed to write to output file: %s", err) - } - fi, err := os.Stat(f.Name()) - if err != nil { - t.Errorf("failed to stat output file: %s", err) - } - if fi == nil { - t.Errorf("received empty file handle") - return - } - if fi.Size() <= 0 { - t.Errorf("output file is expected to contain data but its size is zero") - } -} - -// TestMsg_GetGenHeader will test the GetGenHeader method of the Msg -func TestMsg_GetGenHeader(t *testing.T) { - m := NewMsg() - m.Subject("this is a test") - sa := m.GetGenHeader(HeaderSubject) - if len(sa) <= 0 { - t.Errorf("GetGenHeader on subject failed. Got empty slice") - return - } - if sa[0] == "" { - t.Errorf("GetGenHeader on subject failed. Got empty value") - } - if sa[0] != "this is a test" { - t.Errorf("GetGenHeader on subject failed. Expected: %q, got: %q", "this is a test", sa[0]) - } -} - -// TestMsg_GetAddrHeader will test the Msg.GetAddrHeader method -func TestMsg_GetAddrHeader(t *testing.T) { - m := NewMsg() - if err := m.FromFormat("Toni Sender", "sender@example.com"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - } - if err := m.AddToFormat("Toni To", "to@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - if err := m.AddCcFormat("Toni Cc", "cc@example.com"); err != nil { - t.Errorf("failed to set CC address: %s", err) - } - if err := m.AddBccFormat("Toni Bcc", "bcc@example.com"); err != nil { - t.Errorf("failed to set BCC address: %s", err) - } - fh := m.GetAddrHeader(HeaderFrom) - if len(fh) <= 0 { - t.Errorf("GetAddrHeader on FROM failed. Got empty slice") - return - } - if fh[0].String() == "" { - t.Errorf("GetAddrHeader on FROM failed. Got empty value") - } - if fh[0].String() != `"Toni Sender" ` { - t.Errorf("GetAddrHeader on FROM failed. Expected: %q, got: %q", - `"Toni Sender" "`, fh[0].String()) - } - th := m.GetAddrHeader(HeaderTo) - if len(th) <= 0 { - t.Errorf("GetAddrHeader on TO failed. Got empty slice") - return - } - if th[0].String() == "" { - t.Errorf("GetAddrHeader on TO failed. Got empty value") - } - if th[0].String() != `"Toni To" ` { - t.Errorf("GetAddrHeader on TO failed. Expected: %q, got: %q", - `"Toni To" "`, th[0].String()) - } - ch := m.GetAddrHeader(HeaderCc) - if len(ch) <= 0 { - t.Errorf("GetAddrHeader on CC failed. Got empty slice") - return - } - if ch[0].String() == "" { - t.Errorf("GetAddrHeader on CC failed. Got empty value") - } - if ch[0].String() != `"Toni Cc" ` { - t.Errorf("GetAddrHeader on CC failed. Expected: %q, got: %q", - `"Toni Cc" "`, ch[0].String()) - } - bh := m.GetAddrHeader(HeaderBcc) - if len(bh) <= 0 { - t.Errorf("GetAddrHeader on BCC failed. Got empty slice") - return - } - if bh[0].String() == "" { - t.Errorf("GetAddrHeader on BCC failed. Got empty value") - } - if bh[0].String() != `"Toni Bcc" ` { - t.Errorf("GetAddrHeader on BCC failed. Expected: %q, got: %q", - `"Toni Bcc" "`, bh[0].String()) - } -} - -// TestMsg_GetFrom will test the Msg.GetFrom method -func TestMsg_GetFrom(t *testing.T) { - m := NewMsg() - if err := m.FromFormat("Toni Sender", "sender@example.com"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - } - fh := m.GetFrom() - if len(fh) <= 0 { - t.Errorf("GetFrom failed. Got empty slice") - return - } - if fh[0].String() == "" { - t.Errorf("GetFrom failed. Got empty value") - } - if fh[0].String() != `"Toni Sender" ` { - t.Errorf("GetFrom failed. Expected: %q, got: %q", - `"Toni Sender" "`, fh[0].String()) - } -} - -// TestMsg_GetFromString will test the Msg.GetFromString method -func TestMsg_GetFromString(t *testing.T) { - m := NewMsg() - if err := m.FromFormat("Toni Sender", "sender@example.com"); err != nil { - t.Errorf("failed to set FROM address: %s", err) - } - fh := m.GetFromString() - if len(fh) <= 0 { - t.Errorf("GetFromString failed. Got empty slice") - return - } - if fh[0] == "" { - t.Errorf("GetFromString failed. Got empty value") - } - if fh[0] != `"Toni Sender" ` { - t.Errorf("GetFromString failed. Expected: %q, got: %q", - `"Toni Sender" "`, fh[0]) - } -} - -// TestMsg_GetTo will test the Msg.GetTo method -func TestMsg_GetTo(t *testing.T) { - m := NewMsg() - if err := m.AddToFormat("Toni To", "to@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - fh := m.GetTo() - if len(fh) <= 0 { - t.Errorf("GetTo failed. Got empty slice") - return - } - if fh[0].String() == "" { - t.Errorf("GetTo failed. Got empty value") - } - if fh[0].String() != `"Toni To" ` { - t.Errorf("GetTo failed. Expected: %q, got: %q", - `"Toni To" "`, fh[0].String()) - } -} - -// TestMsg_GetToString will test the Msg.GetToString method -func TestMsg_GetToString(t *testing.T) { - m := NewMsg() - if err := m.AddToFormat("Toni To", "to@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - fh := m.GetToString() - if len(fh) <= 0 { - t.Errorf("GetToString failed. Got empty slice") - return - } - if fh[0] == "" { - t.Errorf("GetToString failed. Got empty value") - } - if fh[0] != `"Toni To" ` { - t.Errorf("GetToString failed. Expected: %q, got: %q", - `"Toni To" "`, fh[0]) - } -} - -// TestMsg_GetCc will test the Msg.GetCc method -func TestMsg_GetCc(t *testing.T) { - m := NewMsg() - if err := m.AddCcFormat("Toni Cc", "cc@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - fh := m.GetCc() - if len(fh) <= 0 { - t.Errorf("GetCc failed. Got empty slice") - return - } - if fh[0].String() == "" { - t.Errorf("GetCc failed. Got empty value") - } - if fh[0].String() != `"Toni Cc" ` { - t.Errorf("GetCc failed. Expected: %q, got: %q", - `"Toni Cc" "`, fh[0].String()) - } -} - -// TestMsg_GetCcString will test the Msg.GetCcString method -func TestMsg_GetCcString(t *testing.T) { - m := NewMsg() - if err := m.AddCcFormat("Toni Cc", "cc@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - fh := m.GetCcString() - if len(fh) <= 0 { - t.Errorf("GetCcString failed. Got empty slice") - return - } - if fh[0] == "" { - t.Errorf("GetCcString failed. Got empty value") - } - if fh[0] != `"Toni Cc" ` { - t.Errorf("GetCcString failed. Expected: %q, got: %q", - `"Toni Cc" "`, fh[0]) - } -} - -// TestMsg_GetBcc will test the Msg.GetBcc method -func TestMsg_GetBcc(t *testing.T) { - m := NewMsg() - if err := m.AddBccFormat("Toni Bcc", "bcc@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - fh := m.GetBcc() - if len(fh) <= 0 { - t.Errorf("GetBcc failed. Got empty slice") - return - } - if fh[0].String() == "" { - t.Errorf("GetBcc failed. Got empty value") - } - if fh[0].String() != `"Toni Bcc" ` { - t.Errorf("GetBcc failed. Expected: %q, got: %q", - `"Toni Cc" "`, fh[0].String()) - } -} - -// TestMsg_GetBccString will test the Msg.GetBccString method -func TestMsg_GetBccString(t *testing.T) { - m := NewMsg() - if err := m.AddBccFormat("Toni Bcc", "bcc@example.com"); err != nil { - t.Errorf("failed to set TO address: %s", err) - } - fh := m.GetBccString() - if len(fh) <= 0 { - t.Errorf("GetBccString failed. Got empty slice") - return - } - if fh[0] == "" { - t.Errorf("GetBccString failed. Got empty value") - } - if fh[0] != `"Toni Bcc" ` { - t.Errorf("GetBccString failed. Expected: %q, got: %q", - `"Toni Cc" "`, fh[0]) - } -} - -// TestMsg_GetBoundary will test the Msg.GetBoundary method -func TestMsg_GetBoundary(t *testing.T) { - b := "random_boundary_string" - m := NewMsg() - if boundary := m.GetBoundary(); boundary != "" { - t.Errorf("GetBoundary failed. Expected empty string, but got: %s", boundary) - } - m = NewMsg(WithBoundary(b)) - if boundary := m.GetBoundary(); boundary != b { - t.Errorf("GetBoundary failed. Expected boundary: %s, got: %s", b, boundary) - } -} - -// TestMsg_AttachEmbedReader_consecutive tests the Msg.AttachReader and Msg.EmbedReader -// methods with consecutive calls to Msg.WriteTo to make sure the attachments are not -// lost (see Github issue #110) -func TestMsg_AttachEmbedReader_consecutive(t *testing.T) { - ts1 := "This is a test string" - ts2 := "Another test string" - m := NewMsg() - if err := m.AttachReader("attachment.txt", bytes.NewBufferString(ts1)); err != nil { - t.Errorf("AttachReader() failed. Expected no error, got: %s", err.Error()) - return - } - if err := m.EmbedReader("embedded.txt", bytes.NewBufferString(ts2)); err != nil { - t.Errorf("EmbedReader() failed. Expected no error, got: %s", err.Error()) - return - } - obuf1 := &bytes.Buffer{} - obuf2 := &bytes.Buffer{} - _, err := m.WriteTo(obuf1) - if err != nil { - t.Errorf("WriteTo to first output buffer failed: %s", err) - } - _, err = m.WriteTo(obuf2) - if err != nil { - t.Errorf("WriteTo to second output buffer failed: %s", err) - } - if !strings.Contains(obuf1.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { - t.Errorf("Expected file attachment string not found in first output buffer") - } - if !strings.Contains(obuf2.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { - t.Errorf("Expected file attachment string not found in second output buffer") - } - if !strings.Contains(obuf1.String(), "QW5vdGhlciB0ZXN0IHN0cmluZw==") { - t.Errorf("Expected embedded file string not found in first output buffer") - } - if !strings.Contains(obuf2.String(), "QW5vdGhlciB0ZXN0IHN0cmluZw==") { - t.Errorf("Expected embded file string not found in second output buffer") - } -} - -// TestMsg_AttachEmbedReadSeeker_consecutive tests the Msg.AttachReadSeeker and -// Msg.EmbedReadSeeker methods with consecutive calls to Msg.WriteTo to make -// sure the attachments are not lost (see Github issue #110) -func TestMsg_AttachEmbedReadSeeker_consecutive(t *testing.T) { - ts1 := []byte("This is a test string") - ts2 := []byte("Another test string") - m := NewMsg() - m.AttachReadSeeker("attachment.txt", bytes.NewReader(ts1)) - m.EmbedReadSeeker("embedded.txt", bytes.NewReader(ts2)) - obuf1 := &bytes.Buffer{} - obuf2 := &bytes.Buffer{} - _, err := m.WriteTo(obuf1) - if err != nil { - t.Errorf("WriteTo to first output buffer failed: %s", err) - } - _, err = m.WriteTo(obuf2) - if err != nil { - t.Errorf("WriteTo to second output buffer failed: %s", err) - } - if !strings.Contains(obuf1.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { - t.Errorf("Expected file attachment string not found in first output buffer") - } - if !strings.Contains(obuf2.String(), "VGhpcyBpcyBhIHRlc3Qgc3RyaW5n") { - t.Errorf("Expected file attachment string not found in second output buffer") - } - if !strings.Contains(obuf1.String(), "QW5vdGhlciB0ZXN0IHN0cmluZw==") { - t.Errorf("Expected embedded file string not found in first output buffer") - } - if !strings.Contains(obuf2.String(), "QW5vdGhlciB0ZXN0IHN0cmluZw==") { - t.Errorf("Expected embded file string not found in second output buffer") - } -} - -// TestMsg_AttachReadSeeker tests the Msg.AttachReadSeeker method -func TestMsg_AttachReadSeeker(t *testing.T) { - m := NewMsg() - ts := []byte("This is a test string") - r := bytes.NewReader(ts) - m.AttachReadSeeker("testfile.txt", r) - if len(m.attachments) != 1 { - t.Errorf("AttachReadSeeker() failed. Number of attachments expected: %d, got: %d", 1, - len(m.attachments)) - return - } - file := m.attachments[0] - if file == nil { - t.Errorf("AttachReadSeeker() failed. Attachment file pointer is nil") - return - } - if file.Name != "testfile.txt" { - t.Errorf("AttachReadSeeker() failed. Expected file name: %s, got: %s", "testfile.txt", - file.Name) - } - wbuf := bytes.Buffer{} - if _, err := file.Writer(&wbuf); err != nil { - t.Errorf("execute WriterFunc failed: %s", err) - } - if wbuf.String() != string(ts) { - t.Errorf("AttachReadSeeker() failed. Expected string: %q, got: %q", ts, wbuf.String()) - } -} - -// TestMsg_EmbedReadSeeker tests the Msg.EmbedReadSeeker method -func TestMsg_EmbedReadSeeker(t *testing.T) { - m := NewMsg() - ts := []byte("This is a test string") - r := bytes.NewReader(ts) - m.EmbedReadSeeker("testfile.txt", r) - if len(m.embeds) != 1 { - t.Errorf("EmbedReadSeeker() failed. Number of attachments expected: %d, got: %d", 1, - len(m.embeds)) - return - } - file := m.embeds[0] - if file == nil { - t.Errorf("EmbedReadSeeker() failed. Embedded file pointer is nil") - return - } - if file.Name != "testfile.txt" { - t.Errorf("EmbedReadSeeker() failed. Expected file name: %s, got: %s", "testfile.txt", - file.Name) - } - wbuf := bytes.Buffer{} - if _, err := file.Writer(&wbuf); err != nil { - t.Errorf("execute WriterFunc failed: %s", err) - } - if wbuf.String() != string(ts) { - t.Errorf("EmbedReadSeeker() failed. Expected string: %q, got: %q", ts, wbuf.String()) - } -} - -// TestMsg_ToFromString tests Msg.ToFromString in different scenarios -func TestMsg_ToFromString(t *testing.T) { - tests := []struct { - n string - v string - w []*mail.Address - sf bool - }{ - {"valid single address", "test@test.com", []*mail.Address{ - {Name: "", Address: "test@test.com"}, - }, false}, - { - "valid multiple addresses", "test@test.com,test2@example.com", - []*mail.Address{ - {Name: "", Address: "test@test.com"}, - {Name: "", Address: "test2@example.com"}, - }, - false, - }, - { - "valid multiple addresses with space and name", - `test@test.com, "Toni Tester" `, - []*mail.Address{ - {Name: "", Address: "test@test.com"}, - {Name: "Toni Tester", Address: "test2@example.com"}, - }, - false, - }, - { - "invalid and valid multiple addresses", "test@test.com,test2#example.com", nil, - true, - }, - } - - for _, tt := range tests { - t.Run(tt.n, func(t *testing.T) { - m := NewMsg() - if err := m.ToFromString(tt.v); err != nil && !tt.sf { - t.Errorf("Msg.ToFromString failed: %s", err) - return - } - mto := m.GetTo() - if len(mto) != len(tt.w) { - t.Errorf("Msg.ToFromString failed, expected len: %d, got: %d", len(tt.w), - len(mto)) - return - } - for i := range mto { - w := tt.w[i] - g := mto[i] - if w.String() != g.String() { - t.Errorf("Msg.ToFromString failed, expected address: %s, got: %s", - w.String(), g.String()) - } - } - }) - } -} - -// TestMsg_CcFromString tests Msg.CcFromString in different scenarios -func TestMsg_CcFromString(t *testing.T) { - tests := []struct { - n string - v string - w []*mail.Address - sf bool - }{ - {"valid single address", "test@test.com", []*mail.Address{ - {Name: "", Address: "test@test.com"}, - }, false}, - { - "valid multiple addresses", "test@test.com,test2@example.com", - []*mail.Address{ - {Name: "", Address: "test@test.com"}, - {Name: "", Address: "test2@example.com"}, - }, - false, - }, - { - "valid multiple addresses with space and name", - `test@test.com, "Toni Tester" `, - []*mail.Address{ - {Name: "", Address: "test@test.com"}, - {Name: "Toni Tester", Address: "test2@example.com"}, - }, - false, - }, - { - "invalid and valid multiple addresses", "test@test.com,test2#example.com", nil, - true, - }, - } - - for _, tt := range tests { - t.Run(tt.n, func(t *testing.T) { - m := NewMsg() - if err := m.CcFromString(tt.v); err != nil && !tt.sf { - t.Errorf("Msg.CcFromString failed: %s", err) - return - } - mto := m.GetCc() - if len(mto) != len(tt.w) { - t.Errorf("Msg.CcFromString failed, expected len: %d, got: %d", len(tt.w), - len(mto)) - return - } - for i := range mto { - w := tt.w[i] - g := mto[i] - if w.String() != g.String() { - t.Errorf("Msg.CcFromString failed, expected address: %s, got: %s", - w.String(), g.String()) - } - } - }) - } -} - -// TestMsg_BccFromString tests Msg.BccFromString in different scenarios -func TestMsg_BccFromString(t *testing.T) { - tests := []struct { - n string - v string - w []*mail.Address - sf bool - }{ - {"valid single address", "test@test.com", []*mail.Address{ - {Name: "", Address: "test@test.com"}, - }, false}, - { - "valid multiple addresses", "test@test.com,test2@example.com", - []*mail.Address{ - {Name: "", Address: "test@test.com"}, - {Name: "", Address: "test2@example.com"}, - }, - false, - }, - { - "valid multiple addresses with space and name", - `test@test.com, "Toni Tester" `, - []*mail.Address{ - {Name: "", Address: "test@test.com"}, - {Name: "Toni Tester", Address: "test2@example.com"}, - }, - false, - }, - { - "invalid and valid multiple addresses", "test@test.com,test2#example.com", nil, - true, - }, - } - - for _, tt := range tests { - t.Run(tt.n, func(t *testing.T) { - m := NewMsg() - if err := m.BccFromString(tt.v); err != nil && !tt.sf { - t.Errorf("Msg.BccFromString failed: %s", err) - return - } - mto := m.GetBcc() - if len(mto) != len(tt.w) { - t.Errorf("Msg.BccFromString failed, expected len: %d, got: %d", len(tt.w), - len(mto)) - return - } - for i := range mto { - w := tt.w[i] - g := mto[i] - if w.String() != g.String() { - t.Errorf("Msg.BccFromString failed, expected address: %s, got: %s", - w.String(), g.String()) - } - } - }) - } -} - -// TestMsg_checkUserAgent tests the checkUserAgent method of the Msg -func TestMsg_checkUserAgent(t *testing.T) { - tests := []struct { - name string - noDefaultUserAgent bool - genHeader map[Header][]string - wantUserAgent string - sf bool - }{ - { - name: "check default user agent", - noDefaultUserAgent: false, - wantUserAgent: fmt.Sprintf("go-mail v%s // https://github.com/wneessen/go-mail", VERSION), - sf: false, - }, - { - name: "check no default user agent", - noDefaultUserAgent: true, - wantUserAgent: "", - sf: true, - }, - { - name: "check if ua and xm is already set", - noDefaultUserAgent: false, - genHeader: map[Header][]string{ - HeaderUserAgent: {"custom UA"}, - HeaderXMailer: {"custom XM"}, - }, - wantUserAgent: "custom UA", - sf: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg := &Msg{ - noDefaultUserAgent: tt.noDefaultUserAgent, - genHeader: tt.genHeader, - } - msg.checkUserAgent() - gotUserAgent := "" - if val, ok := msg.genHeader[HeaderUserAgent]; ok { - gotUserAgent = val[0] // Assuming the first one is the needed value - } - if gotUserAgent != tt.wantUserAgent && !tt.sf { - t.Errorf("UserAgent got = %v, want = %v", gotUserAgent, tt.wantUserAgent) - } - }) - } -} - -// TestNewMsgWithMIMEVersion tests WithMIMEVersion and Msg.SetMIMEVersion -func TestNewMsgWithNoDefaultUserAgent(t *testing.T) { - m := NewMsg(WithNoDefaultUserAgent()) - if m.noDefaultUserAgent != true { - t.Errorf("WithNoDefaultUserAgent() failed. Expected: %t, got: %t", true, false) - } + return false } // Fuzzing tests diff --git a/msg_unix_test.go b/msg_unix_test.go new file mode 100644 index 0000000..57e1547 --- /dev/null +++ b/msg_unix_test.go @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +//go:build linux || freebsd +// +build linux freebsd + +package mail + +import ( + "bytes" + "errors" + "os" + "testing" +) + +func TestMsg_AttachFile_unixOnly(t *testing.T) { + t.Run("AttachFile with fileFromFS fails on open", func(t *testing.T) { + if os.Getenv("PERFORM_UNIX_OPEN_WRITE_TESTS") != "true" { + t.Skipf("PERFORM_UNIX_OPEN_WRITE_TESTS variable is not set. Skipping unix open/write tests") + } + + tempFile, err := os.CreateTemp("", "attachfile-open-write-test.*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + t.Cleanup(func() { + if err := os.Remove(tempFile.Name()); err != nil { + t.Errorf("failed to remove temp file: %s", err) + } + }) + if err = os.Chmod(tempFile.Name(), 0o000); err != nil { + t.Fatalf("failed to chmod temp file: %s", err) + } + + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.AttachFile(tempFile.Name()) + attachments := message.GetAttachments() + if len(attachments) != 1 { + t.Fatalf("failed to get attachments, expected 1, got: %d", len(attachments)) + } + messageBuf := bytes.NewBuffer(nil) + _, err = attachments[0].Writer(messageBuf) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + if !errors.Is(err, os.ErrPermission) { + t.Errorf("expected error to be %s, got: %s", os.ErrPermission, err) + } + }) +} + +func TestMsg_EmbedFile_unixOnly(t *testing.T) { + t.Run("EmbedFile with fileFromFS fails on open", func(t *testing.T) { + if os.Getenv("PERFORM_UNIX_OPEN_WRITE_TESTS") != "true" { + t.Skipf("PERFORM_UNIX_OPEN_WRITE_TESTS variable is not set. Skipping unix open/write tests") + } + + tempFile, err := os.CreateTemp("", "embedfile-open-write-test.*.txt") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + t.Cleanup(func() { + if err := os.Remove(tempFile.Name()); err != nil { + t.Errorf("failed to remove temp file: %s", err) + } + }) + if err = os.Chmod(tempFile.Name(), 0o000); err != nil { + t.Fatalf("failed to chmod temp file: %s", err) + } + + message := NewMsg() + if message == nil { + t.Fatal("message is nil") + } + message.EmbedFile(tempFile.Name()) + embeds := message.GetEmbeds() + if len(embeds) != 1 { + t.Fatalf("failed to get embeds, expected 1, got: %d", len(embeds)) + } + messageBuf := bytes.NewBuffer(nil) + _, err = embeds[0].Writer(messageBuf) + if err == nil { + t.Error("writer func expected to fail, but didn't") + } + if !errors.Is(err, os.ErrPermission) { + t.Errorf("expected error to be %s, got: %s", os.ErrPermission, err) + } + }) +} + +func TestMsg_WriteToFile_unixOnly(t *testing.T) { + t.Run("WriteToFile fails on create", func(t *testing.T) { + if os.Getenv("PERFORM_UNIX_OPEN_WRITE_TESTS") != "true" { + t.Skipf("PERFORM_UNIX_OPEN_WRITE_TESTS variable is not set. Skipping unix open/write tests") + } + + tempfile, err := os.CreateTemp("", "testmail-create.*.eml") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + if err = os.Chmod(tempfile.Name(), 0o000); err != nil { + t.Fatalf("failed to chmod temp file: %s", err) + } + t.Cleanup(func() { + if err = tempfile.Close(); err != nil { + t.Fatalf("failed to close temp file: %s", err) + } + if err = os.Remove(tempfile.Name()); err != nil { + t.Fatalf("failed to remove temp file: %s", err) + } + }) + message := testMessage(t) + if err = message.WriteToFile(tempfile.Name()); err == nil { + t.Errorf("expected error, got nil") + } + }) +} + +func TestMsg_WriteToTempFile_unixOnly(t *testing.T) { + if os.Getenv("PERFORM_UNIX_OPEN_WRITE_TESTS") != "true" { + t.Skipf("PERFORM_UNIX_OPEN_WRITE_TESTS variable is not set. Skipping unix open/write tests") + } + + t.Run("WriteToTempFile fails on invalid TMPDIR", func(t *testing.T) { + // We store the current TMPDIR variable so we can set it back when the test is over + curTmpDir := os.Getenv("TMPDIR") + t.Cleanup(func() { + if err := os.Setenv("TMPDIR", curTmpDir); err != nil { + t.Errorf("failed to set TMPDIR environment variable: %s", err) + } + }) + + if err := os.Setenv("TMPDIR", "/invalid/directory/that/does/not/exist"); err != nil { + t.Fatalf("failed to set TMPDIR environment variable: %s", err) + } + message := testMessage(t) + _, err := message.WriteToTempFile() + if err == nil { + t.Errorf("expected writing to invalid TMPDIR to fail, got: %s", err) + } + }) +} diff --git a/msgwriter_test.go b/msgwriter_test.go index a41e5d3..330507b 100644 --- a/msgwriter_test.go +++ b/msgwriter_test.go @@ -6,151 +6,673 @@ package mail import ( "bytes" + "errors" "fmt" "io" "mime" + "runtime" "strings" "testing" "time" ) -// brokenWriter implements a broken writer for io.Writer testing -type brokenWriter struct { - io.Writer -} - -// Write implements the io.Writer interface but intentionally returns an error at -// any time -func (bw *brokenWriter) Write([]byte) (int, error) { - return 0, fmt.Errorf("intentionally failed") -} - -// TestMsgWriter_Write tests the WriteTo() method of the msgWriter func TestMsgWriter_Write(t *testing.T) { - bw := &brokenWriter{} - mw := &msgWriter{writer: bw, charset: CharsetUTF8, encoder: mime.QEncoding} - _, err := mw.Write([]byte("test")) - if err == nil { - t.Errorf("msgWriter WriteTo() with brokenWriter should fail, but didn't") - } - - // Also test the part when a previous error happened - mw.err = fmt.Errorf("broken") - _, err = mw.Write([]byte("test")) - if err == nil { - t.Errorf("msgWriter WriteTo() with brokenWriter should fail, but didn't") - } -} - -// TestMsgWriter_writeMsg tests the writeMsg method of the msgWriter -func TestMsgWriter_writeMsg(t *testing.T) { - m := NewMsg() - _ = m.From(`"Toni Tester" `) - _ = m.To(`"Toni Receiver" `) - m.Subject("This is a subject") - m.SetBulk() - now := time.Now() - m.SetDateWithValue(now) - m.SetMessageIDWithValue("message@id.com") - m.SetBodyString(TypeTextPlain, "This is the body") - m.AddAlternativeString(TypeTextHTML, "This is the alternative body") - buf := bytes.Buffer{} - mw := &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding} - mw.writeMsg(m) - ms := buf.String() - - var ea []string - if !strings.Contains(ms, `MIME-Version: 1.0`) { - ea = append(ea, "MIME-Version") - } - if !strings.Contains(ms, fmt.Sprintf("Date: %s", now.Format(time.RFC1123Z))) { - ea = append(ea, "Date") - } - if !strings.Contains(ms, `Message-ID: `) { - ea = append(ea, "Message-ID") - } - if !strings.Contains(ms, `Precedence: bulk`) { - ea = append(ea, "Precedence") - } - if !strings.Contains(ms, `Subject: This is a subject`) { - ea = append(ea, "Subject") - } - if !strings.Contains(ms, `User-Agent: go-mail v`) { - ea = append(ea, "User-Agent") - } - if !strings.Contains(ms, `X-Mailer: go-mail v`) { - ea = append(ea, "X-Mailer") - } - if !strings.Contains(ms, `From: "Toni Tester" `) { - ea = append(ea, "From") - } - if !strings.Contains(ms, `To: "Toni Receiver" `) { - ea = append(ea, "To") - } - if !strings.Contains(ms, `Content-Type: text/plain; charset=UTF-8`) { - ea = append(ea, "Content-Type") - } - if !strings.Contains(ms, `Content-Transfer-Encoding: quoted-printable`) { - ea = append(ea, "Content-Transfer-Encoding") - } - if !strings.Contains(ms, "\r\n\r\nThis is the body") { - ea = append(ea, "Message body") - } - - pl := m.GetParts() - if len(pl) <= 0 { - t.Errorf("expected multiple parts but got none") - return - } - if len(pl) == 2 { - ap := pl[1] - ap.SetCharset(CharsetISO88591) - } - buf.Reset() - mw.writeMsg(m) - ms = buf.String() - if !strings.Contains(ms, "\r\n\r\nThis is the alternative body") { - ea = append(ea, "Message alternative body") - } - if !strings.Contains(ms, `Content-Type: text/html; charset=ISO-8859-1`) { - ea = append(ea, "alternative body charset") - } - - if len(ea) > 0 { - em := "writeMsg() failed. The following errors occurred:\n" - for e := range ea { - em += fmt.Sprintf("* incorrect %q field", ea[e]) + t.Run("msgWriter writes to memory for all charsets", func(t *testing.T) { + for _, tt := range charsetTests { + t.Run(tt.name, func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter := &msgWriter{ + writer: buffer, + charset: tt.value, + encoder: mime.QEncoding, + } + _, err := msgwriter.Write([]byte("test")) + if err != nil { + t.Errorf("msgWriter failed to write: %s", err) + } + }) } - em += fmt.Sprintf("\n\nFull message:\n%s", ms) - t.Error(em) - } + }) + t.Run("msgWriter writes to memory for all encodings", func(t *testing.T) { + for _, tt := range encodingTests { + t.Run(tt.name, func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter := &msgWriter{ + writer: buffer, + charset: CharsetUTF8, + encoder: getEncoder(tt.value), + } + _, err := msgwriter.Write([]byte("test")) + if err != nil { + t.Errorf("msgWriter failed to write: %s", err) + } + }) + } + }) + t.Run("msgWriter should fail on write", func(t *testing.T) { + msgwriter := &msgWriter{ + writer: failReadWriteSeekCloser{}, + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + _, err := msgwriter.Write([]byte("test")) + if err == nil { + t.Fatalf("msgWriter was supposed to fail on write") + } + }) + t.Run("msgWriter should fail on previous error", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter := &msgWriter{ + writer: buffer, + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + _, err := msgwriter.Write([]byte("test")) + if err != nil { + t.Errorf("msgWriter failed to write: %s", err) + } + msgwriter.err = errors.New("intentionally failed") + _, err = msgwriter.Write([]byte("test2")) + if err == nil { + t.Fatalf("msgWriter was supposed to fail on second write") + } + }) } -// TestMsgWriter_writeMsg_PGP tests the writeMsg method of the msgWriter with PGP types set -func TestMsgWriter_writeMsg_PGP(t *testing.T) { - m := NewMsg(WithPGPType(PGPEncrypt)) - _ = m.From(`"Toni Tester" `) - _ = m.To(`"Toni Receiver" `) - m.Subject("This is a subject") - m.SetBodyString(TypeTextPlain, "This is the body") - buf := bytes.Buffer{} - mw := &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding} - mw.writeMsg(m) - ms := buf.String() - if !strings.Contains(ms, `encrypted; protocol="application/pgp-encrypted"`) { - t.Errorf("writeMsg failed. Expected PGP encoding header but didn't find it in message output") +func TestMsgWriter_writeMsg(t *testing.T) { + msgwriter := &msgWriter{ + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), } + t.Run("msgWriter writes a simple message", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + now := time.Now() + msgwriter.writer = buffer + message := testMessage(t) + message.SetDateWithValue(now) + message.SetMessageIDWithValue("message@id.com") + message.SetBulk() + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } - m = NewMsg(WithPGPType(PGPSignature)) - _ = m.From(`"Toni Tester" `) - _ = m.To(`"Toni Receiver" `) - m.Subject("This is a subject") - m.SetBodyString(TypeTextPlain, "This is the body") - buf = bytes.Buffer{} - mw = &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding} - mw.writeMsg(m) - ms = buf.String() - if !strings.Contains(ms, `signed; protocol="application/pgp-signature"`) { - t.Errorf("writeMsg failed. Expected PGP encoding header but didn't find it in message output") - } + var incorrectFields []string + if !strings.Contains(buffer.String(), "MIME-Version: 1.0\r\n") { + incorrectFields = append(incorrectFields, "MIME-Version") + } + if !strings.Contains(buffer.String(), fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z))) { + incorrectFields = append(incorrectFields, "Date") + } + if !strings.Contains(buffer.String(), "Message-ID: \r\n") { + incorrectFields = append(incorrectFields, "Message-ID") + } + if !strings.Contains(buffer.String(), "Precedence: bulk\r\n") { + incorrectFields = append(incorrectFields, "Precedence") + } + if !strings.Contains(buffer.String(), "X-Auto-Response-Suppress: All\r\n") { + incorrectFields = append(incorrectFields, "X-Auto-Response-Suppress") + } + if !strings.Contains(buffer.String(), "Subject: Testmail\r\n") { + incorrectFields = append(incorrectFields, "Subject") + } + if !strings.Contains(buffer.String(), "User-Agent: go-mail v") { + incorrectFields = append(incorrectFields, "User-Agent") + } + if !strings.Contains(buffer.String(), "X-Mailer: go-mail v") { + incorrectFields = append(incorrectFields, "X-Mailer") + } + if !strings.Contains(buffer.String(), `From: <`+TestSenderValid+`>`) { + incorrectFields = append(incorrectFields, "From") + } + if !strings.Contains(buffer.String(), `To: <`+TestRcptValid+`>`) { + incorrectFields = append(incorrectFields, "From") + } + if !strings.Contains(buffer.String(), "Content-Type: text/plain; charset=UTF-8\r\n") { + incorrectFields = append(incorrectFields, "Content-Type") + } + if !strings.Contains(buffer.String(), "Content-Transfer-Encoding: quoted-printable\r\n") { + incorrectFields = append(incorrectFields, "Content-Transfer-Encoding") + } + if !strings.HasSuffix(buffer.String(), "\r\n\r\nTestmail") { + incorrectFields = append(incorrectFields, "Message body") + } + if len(incorrectFields) > 0 { + t.Fatalf("msgWriter failed to write correct fields: %s - mail: %s", + strings.Join(incorrectFields, ", "), buffer.String()) + } + }) + t.Run("msgWriter with no from address uses envelope from", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := NewMsg() + if message == nil { + t.Fatal("failed to create new message") + } + if err := message.EnvelopeFrom(TestSenderValid); err != nil { + t.Errorf("failed to set sender address: %s", err) + } + if err := message.To(TestRcptValid); err != nil { + t.Errorf("failed to set recipient address: %s", err) + } + message.Subject("Testmail") + message.SetBodyString(TypeTextPlain, "Testmail") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "From: <"+TestSenderValid+">") { + t.Errorf("expected envelope from address as from address, got: %s", buffer.String()) + } + }) + t.Run("msgWriter with no from address or envelope from", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := NewMsg() + if message == nil { + t.Fatal("failed to create new message") + } + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if strings.Contains(buffer.String(), "From:") { + t.Errorf("expected no from address, got: %s", buffer.String()) + } + }) + t.Run("msgWriter writes a multipart/mixed message", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithBoundary("testboundary")) + message.AttachFile("testdata/attachment.txt") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "Content-Type: multipart/mixed") { + t.Errorf("expected multipart/mixed, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary\r\n") { + t.Errorf("expected boundary, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary--") { + t.Errorf("expected end boundary, got: %s", buffer.String()) + } + }) + t.Run("msgWriter writes a multipart/related message", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithBoundary("testboundary")) + message.EmbedFile("testdata/embed.txt") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "Content-Type: multipart/related") { + t.Errorf("expected multipart/related, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary\r\n") { + t.Errorf("expected boundary, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary--") { + t.Errorf("expected end boundary, got: %s", buffer.String()) + } + }) + t.Run("msgWriter writes a multipart/alternative message", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithBoundary("testboundary")) + message.AddAlternativeString(TypeTextHTML, "

Testmail

") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "Content-Type: multipart/alternative") { + t.Errorf("expected multipart/alternative, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary\r\n") { + t.Errorf("expected boundary, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary--") { + t.Errorf("expected end boundary, got: %s", buffer.String()) + } + }) + t.Run("msgWriter writes a application/pgp-encrypted message", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithPGPType(PGPEncrypt), WithBoundary("testboundary")) + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "Content-Type: multipart/encrypted") { + t.Errorf("expected multipart/encrypted, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary\r\n") { + t.Errorf("expected boundary, got: %s", buffer.String()) + } + }) + t.Run("msgWriter writes a application/pgp-signature message", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithPGPType(PGPSignature), WithBoundary("testboundary")) + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "Content-Type: multipart/signed") { + t.Errorf("expected multipart/signed, got: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "--testboundary\r\n") { + t.Errorf("expected boundary, got: %s", buffer.String()) + } + }) + t.Run("msgWriter should ignore NoPGP", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithBoundary("testboundary")) + message.pgptype = 9 + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "--testboundary\r\n") { + t.Errorf("expected boundary, got: %s", buffer.String()) + } + }) +} + +func TestMsgWriter_writePreformattedGenHeader(t *testing.T) { + t.Run("message with no preformatted headerset", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter := &msgWriter{ + writer: buffer, + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + message := testMessage(t) + message.SetGenHeaderPreformatted(HeaderContentID, "This is a content id") + msgwriter.writeMsg(message) + if !strings.Contains(buffer.String(), "Content-ID: This is a content id\r\n") { + t.Errorf("expected preformatted header, got: %s", buffer.String()) + } + }) +} + +func TestMsgWriter_addFiles(t *testing.T) { + msgwriter := &msgWriter{ + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + t.Run("message with a single file attached", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.AttachFile("testdata/attachment.txt") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + switch runtime.GOOS { + case "windows": + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudA0K") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudAo=") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) { + t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String()) + } + switch runtime.GOOS { + case "freebsd": + if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), `Content-Type: text/plain; charset=utf-8; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + } + }) + t.Run("message with a single file attached no extension", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.AttachFile("testdata/attachment") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + switch runtime.GOOS { + case "windows": + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudA0K") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudAo=") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment"`) { + t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + }) + t.Run("message with a single file attached custom content-type", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.AttachFile("testdata/attachment.txt", WithFileContentType(TypeAppOctetStream)) + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + switch runtime.GOOS { + case "windows": + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudA0K") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudAo=") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) { + t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + }) + t.Run("message with a single file attached custom transfer-encoding", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.AttachFile("testdata/attachment.txt", WithFileEncoding(EncodingUSASCII)) + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "\r\n\r\nThis is a test attachment") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) { + t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String()) + } + switch runtime.GOOS { + case "freebsd": + if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), `Content-Type: text/plain; charset=utf-8; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Transfer-Encoding: 7bit`) { + t.Errorf("Content-Transfer-Encoding header not found for attachment. Mail: %s", buffer.String()) + } + }) + t.Run("message with a single file attached custom description", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.AttachFile("testdata/attachment.txt", WithFileDescription("Testdescription")) + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + switch runtime.GOOS { + case "windows": + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudA0K") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudAo=") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) { + t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String()) + } + switch runtime.GOOS { + case "freebsd": + if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), `Content-Type: text/plain; charset=utf-8; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Transfer-Encoding: base64`) { + t.Errorf("Content-Transfer-Encoding header not found for attachment. Mail: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), `Content-Description: Testdescription`) { + t.Errorf("Content-Description header not found for attachment. Mail: %s", buffer.String()) + } + }) + t.Run("message with attachment but no body part", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.parts = nil + message.AttachFile("testdata/attachment.txt") + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + switch runtime.GOOS { + case "windows": + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudA0K") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), "VGhpcyBpcyBhIHRlc3QgYXR0YWNobWVudAo=") { + t.Errorf("attachment not found in mail message. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) { + t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String()) + } + switch runtime.GOOS { + case "freebsd": + if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + default: + if !strings.Contains(buffer.String(), `Content-Type: text/plain; charset=utf-8; name="attachment.txt"`) { + t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String()) + } + } + if !strings.Contains(buffer.String(), `Content-Transfer-Encoding: base64`) { + t.Errorf("Content-Transfer-Encoding header not found for attachment. Mail: %s", buffer.String()) + } + }) +} + +func TestMsgWriter_writePart(t *testing.T) { + msgwriter := &msgWriter{ + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + t.Run("message with no part charset should use default message charset", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t, WithCharset(CharsetUTF7)) + message.AddAlternativeString(TypeTextPlain, "thisisatest") + message.parts[1].charset = "" + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "ontent-Type: text/plain; charset=UTF-7\r\n\r\nTestmail") { + t.Errorf("part not found in mail message. Mail: %s", buffer.String()) + } + if !strings.Contains(buffer.String(), "ontent-Type: text/plain; charset=UTF-7\r\n\r\nthisisatest") { + t.Errorf("part not found in mail message. Mail: %s", buffer.String()) + } + }) + t.Run("message with parts that have a description", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + message.AddAlternativeString(TypeTextPlain, "thisisatest") + message.parts[1].description = "thisisadescription" + msgwriter.writeMsg(message) + if msgwriter.err != nil { + t.Errorf("msgWriter failed to write: %s", msgwriter.err) + } + if !strings.Contains(buffer.String(), "Content-Description: thisisadescription") { + t.Errorf("part description not found in mail message. Mail: %s", buffer.String()) + } + }) +} + +func TestMsgWriter_writeString(t *testing.T) { + msgwriter := &msgWriter{ + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + t.Run("writeString succeeds", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + msgwriter.writeString("thisisatest") + if !strings.EqualFold(buffer.String(), "thisisatest") { + t.Errorf("writeString failed, expected: thisisatest got: %s", buffer.String()) + } + }) + t.Run("writeString fails", func(t *testing.T) { + msgwriter.writer = failReadWriteSeekCloser{} + msgwriter.writeString("thisisatest") + if msgwriter.err == nil { + t.Errorf("writeString succeeded, expected error") + } + }) + t.Run("writeString on errored writer should return", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + msgwriter.err = errors.New("intentional error") + msgwriter.writeString("thisisatest") + if !strings.EqualFold(buffer.String(), "") { + t.Errorf("writeString succeeded, expected: empty string, got: %s", buffer.String()) + } + }) +} + +func TestMsgWriter_writeHeader(t *testing.T) { + msgwriter := &msgWriter{ + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + t.Run("writeHeader with single value", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + msgwriter.writeHeader(HeaderMessageID, "this.is.a.test") + if !strings.EqualFold(buffer.String(), "Message-ID: this.is.a.test\r\n") { + t.Errorf("writeHeader failed, expected: %s, got: %s", "Message-ID: this.is.a.test", + buffer.String()) + } + }) + t.Run("writeHeader with multiple values", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + msgwriter.writeHeader(HeaderMessageID, "this.is.a.test", "this.as.well") + if !strings.EqualFold(buffer.String(), "Message-ID: this.is.a.test, this.as.well\r\n") { + t.Errorf("writeHeader failed, expected: %s, got: %s", "Message-ID: this.is.a.test, this.as.well", + buffer.String()) + } + }) + t.Run("writeHeader with no values", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + msgwriter.writeHeader(HeaderMessageID) + // While technically it is permitted to have empty headers, it's recommend to omit them if + // no value is present. We follow this recommendation. + if !strings.EqualFold(buffer.String(), "") { + t.Errorf("writeHeader failed, expected: %s, got: %s", "", buffer.String()) + } + }) + t.Run("writeHeader with very long value", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + msgwriter.writeHeader(HeaderMessageID, strings.Repeat("a", MaxHeaderLength-13), "next-row") + want := "Message-ID:\r\n " + strings.Repeat("a", MaxHeaderLength-13) + ",\r\n next-row\r\n" + if !strings.EqualFold(buffer.String(), want) { + t.Errorf("writeHeader failed, expected: %s, got: %s", want, buffer.String()) + } + }) +} + +func TestMsgWriter_writeBody(t *testing.T) { + t.Log("We only cover some edge-cases here, most of the functionality is tested already very thoroughly.") + + msgwriter := &msgWriter{ + charset: CharsetUTF8, + encoder: getEncoder(EncodingQP), + } + t.Run("writeBody on NoEncoding", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + message := testMessage(t) + msgwriter.writeBody(message.parts[0].writeFunc, NoEncoding) + if msgwriter.err != nil { + t.Errorf("writeBody failed to write: %s", msgwriter.err) + } + }) + t.Run("writeBody on NoEncoding fails on write", func(t *testing.T) { + msgwriter.writer = failReadWriteSeekCloser{} + message := testMessage(t) + msgwriter.writeBody(message.parts[0].writeFunc, NoEncoding) + if msgwriter.err == nil { + t.Errorf("writeBody succeeded, expected error") + } + if !strings.EqualFold(msgwriter.err.Error(), "bodyWriter io.Copy: intentional write failure") { + t.Errorf("expected error: bodyWriter io.Copy: intentional write failure, got: %s", msgwriter.err) + } + }) + t.Run("writeBody on NoEncoding fails on writeFunc", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + writeFunc := func(io.Writer) (int64, error) { + return 0, errors.New("intentional write failure") + } + msgwriter.writeBody(writeFunc, NoEncoding) + if msgwriter.err == nil { + t.Errorf("writeBody succeeded, expected error") + } + if !strings.EqualFold(msgwriter.err.Error(), "bodyWriter function: intentional write failure") { + t.Errorf("expected error: bodyWriter function: intentional write failure, got: %s", msgwriter.err) + } + }) + t.Run("writeBody Quoted-Printable fails on write", func(t *testing.T) { + msgwriter.writer = failReadWriteSeekCloser{} + message := testMessage(t) + msgwriter.writeBody(message.parts[0].writeFunc, EncodingQP) + if msgwriter.err == nil { + t.Errorf("writeBody succeeded, expected error") + } + if !strings.EqualFold(msgwriter.err.Error(), "bodyWriter function: intentional write failure") { + t.Errorf("expected error: bodyWriter function: intentional write failure, got: %s", msgwriter.err) + } + }) + t.Run("writeBody Quoted-Printable fails on writeFunc", func(t *testing.T) { + buffer := bytes.NewBuffer(nil) + msgwriter.writer = buffer + writeFunc := func(io.Writer) (int64, error) { + return 0, errors.New("intentional write failure") + } + msgwriter.writeBody(writeFunc, EncodingQP) + if msgwriter.err == nil { + t.Errorf("writeBody succeeded, expected error") + } + if !strings.EqualFold(msgwriter.err.Error(), "bodyWriter function: intentional write failure") { + t.Errorf("expected error: bodyWriter function: intentional write failure, got: %s", msgwriter.err) + } + }) } diff --git a/smtp/smtp.go b/smtp/smtp.go index 77b5fb0..4841ec8 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -554,9 +554,9 @@ func (c *Client) Noop() error { // Quit sends the QUIT command and closes the connection to the server. func (c *Client) Quit() error { - if err := c.hello(); err != nil { - return err - } + // See https://github.com/golang/go/issues/70011 + _ = c.hello() // ignore error; we're quitting anyhow + _, _, err := c.cmd(221, "QUIT") if err != nil { return err diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go index 737bd58..4fe0481 100644 --- a/smtp/smtp_test.go +++ b/smtp/smtp_test.go @@ -900,6 +900,35 @@ Goodbye. QUIT ` +func TestHELOFailed(t *testing.T) { + serverLines := `502 EH? +502 EH? +221 OK +` + clientLines := `EHLO localhost +HELO localhost +QUIT +` + server := strings.Join(strings.Split(serverLines, "\n"), "\r\n") + client := strings.Join(strings.Split(clientLines, "\n"), "\r\n") + var cmdbuf strings.Builder + bcmdbuf := bufio.NewWriter(&cmdbuf) + var fake faker + fake.ReadWriter = bufio.NewReadWriter(bufio.NewReader(strings.NewReader(server)), bcmdbuf) + c := &Client{Text: textproto.NewConn(fake), localName: "localhost"} + if err := c.Hello("localhost"); err == nil { + t.Fatal("expected EHLO to fail") + } + if err := c.Quit(); err != nil { + t.Errorf("QUIT failed: %s", err) + } + _ = bcmdbuf.Flush() + actual := cmdbuf.String() + if client != actual { + t.Errorf("Got:\n%s\nWant:\n%s", actual, client) + } +} + func TestExtensions(t *testing.T) { fake := func(server string) (c *Client, bcmdbuf *bufio.Writer, cmdbuf *strings.Builder) { server = strings.Join(strings.Split(server, "\n"), "\r\n") diff --git a/testdata/RFC5322-A1-1-invalid-from.eml b/testdata/RFC5322-A1-1-invalid-from.eml new file mode 100644 index 0000000..48a1248 --- /dev/null +++ b/testdata/RFC5322-A1-1-invalid-from.eml @@ -0,0 +1,8 @@ +From: §§§§§§§§ +To: Mary Smith +Subject: Saying Hello +Date: Fri, 21 Nov 1997 09:55:06 -0600 +Message-ID: <1234@local.machine.example> + +This is a message just to say hello. +So, "Hello". diff --git a/testdata/RFC5322-A1-1-invalid-from.eml.license b/testdata/RFC5322-A1-1-invalid-from.eml.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/testdata/RFC5322-A1-1-invalid-from.eml.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/testdata/RFC5322-A1-1.eml b/testdata/RFC5322-A1-1.eml new file mode 100644 index 0000000..adb1a85 --- /dev/null +++ b/testdata/RFC5322-A1-1.eml @@ -0,0 +1,8 @@ +From: John Doe +To: Mary Smith +Subject: Saying Hello +Date: Fri, 21 Nov 1997 09:55:06 -0600 +Message-ID: <1234@local.machine.example> + +This is a message just to say hello. +So, "Hello". \ No newline at end of file diff --git a/testdata/RFC5322-A1-1.eml.license b/testdata/RFC5322-A1-1.eml.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/testdata/RFC5322-A1-1.eml.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/testdata/attachment b/testdata/attachment new file mode 100644 index 0000000..fc21731 --- /dev/null +++ b/testdata/attachment @@ -0,0 +1 @@ +This is a test attachment diff --git a/testdata/attachment.license b/testdata/attachment.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/testdata/attachment.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/testdata/attachment.txt b/testdata/attachment.txt new file mode 100644 index 0000000..fc21731 --- /dev/null +++ b/testdata/attachment.txt @@ -0,0 +1 @@ +This is a test attachment diff --git a/testdata/attachment.txt.license b/testdata/attachment.txt.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/testdata/attachment.txt.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/testdata/embed.txt b/testdata/embed.txt new file mode 100644 index 0000000..9a41257 --- /dev/null +++ b/testdata/embed.txt @@ -0,0 +1 @@ +This is a test embed diff --git a/testdata/embed.txt.license b/testdata/embed.txt.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/testdata/embed.txt.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/testdata/logo.svg b/testdata/logo.svg new file mode 100644 index 0000000..9a8dcaa --- /dev/null +++ b/testdata/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/testdata/logo.svg.base64 b/testdata/logo.svg.base64 new file mode 100644 index 0000000..59e4a6a --- /dev/null +++ b/testdata/logo.svg.base64 @@ -0,0 +1,367 @@ +PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PCFE +T0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53 +My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHdpZHRoPSIxMDAlIiBo +ZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCA2MDAgNjAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJo +dHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3Jn +LzE5OTkveGxpbmsiIHhtbDpzcGFjZT0icHJlc2VydmUiIHhtbG5zOnNlcmlmPSJodHRwOi8vd3d3 +LnNlcmlmLmNvbS8iIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZDtjbGlwLXJ1bGU6ZXZlbm9kZDtz +dHJva2UtbGluZWNhcDpyb3VuZDtzdHJva2UtbGluZWpvaW46cm91bmQ7c3Ryb2tlLW1pdGVybGlt +aXQ6MS41OyI+PHJlY3QgaWQ9ItCc0L7QvdGC0LDQttC90LDRjy3QvtCx0LvQsNGB0YLRjDEiIHNl +cmlmOmlkPSLQnNC+0L3RgtCw0LbQvdCw0Y8g0L7QsdC70LDRgdGC0YwxIiB4PSIwIiB5PSIwIiB3 +aWR0aD0iNjAwIiBoZWlnaHQ9IjYwMCIgc3R5bGU9ImZpbGw6bm9uZTsiLz48Zz48cGF0aCBkPSJN +NTg0LjcyNiw5OC4zOTVjLTAsLTkuNzM4IC03LjkwNywtMTcuNjQ1IC0xNy42NDUsLTE3LjY0NWwt +NTEwLjAyNywwYy05LjczOSwwIC0xNy42NDUsNy45MDcgLTE3LjY0NSwxNy42NDVsLTAsMzE5Ljc5 +NGMtMCw5LjczOSA3LjkwNiwxNy42NDYgMTcuNjQ1LDE3LjY0Nmw1MTAuMDI3LC0wYzkuNzM4LC0w +IDE3LjY0NSwtNy45MDcgMTcuNjQ1LC0xNy42NDZsLTAsLTMxOS43OTRaIiBzdHlsZT0iZmlsbDoj +MmMyYzJjO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyLjFweDsiLz48cGF0aCBkPSJNNTg1LjEy +Niw5OC4zOTVjLTAsLTkuNzM4IC03LjkwNywtMTcuNjQ1IC0xNy42NDUsLTE3LjY0NWwtNDk0Ljgz +OSwwYy05LjczOCwwIC0xNy42NDUsNy45MDcgLTE3LjY0NSwxNy42NDVsMCwzMTkuNzk0YzAsOS43 +MzkgNy45MDcsMTcuNjQ2IDE3LjY0NSwxNy42NDZsNDk0LjgzOSwtMGM5LjczOCwtMCAxNy42NDUs +LTcuOTA3IDE3LjY0NSwtMTcuNjQ2bC0wLC0zMTkuNzk0WiIgc3R5bGU9ImZpbGw6IzM3MzczNztz +dHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Mi4wN3B4OyIvPjxwYXRoIGQ9Ik01NjcuMjU5LDExNC40 +NzJjLTAsLTguNzYgLTcuMTEyLC0xNS44NzIgLTE1Ljg3MiwtMTUuODcybC00NjIuNjUxLDBjLTgu +NzYsMCAtMTUuODcyLDcuMTEyIC0xNS44NzIsMTUuODcybDAsMjg3LjY0MWMwLDguNzYgNy4xMTIs +MTUuODcxIDE1Ljg3MiwxNS44NzFsNDYyLjY1MSwwYzguNzYsMCAxNS44NzIsLTcuMTExIDE1Ljg3 +MiwtMTUuODcxbC0wLC0yODcuNjQxWiIgc3R5bGU9ImZpbGw6I2ZmZjJlYjtzdHJva2U6IzAwMDtz +dHJva2Utd2lkdGg6MS45cHg7Ii8+PHBhdGggZD0iTTIzNi4xNDUsNDM1Ljg3OGwtMjMuNDIsOTEu +MDA4bDEzMC43NjUsLTBsMjIuMzY1LC05MC43NTZsLTEyOS43MSwtMC4yNTJaIiBzdHlsZT0iZmls +bDojMmMyYzJjO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyLjQ3cHg7Ii8+PHBhdGggZD0iTTI0 +NS4zOTcsNDM1Ljg3OGwtMjMuNDIxLDkxLjAwOGwxMzAuNzY2LC0wbDIyLjM2NSwtOTAuNzU2bC0x +MjkuNzEsLTAuMjUyWiIgc3R5bGU9ImZpbGw6IzM3MzczNztzdHJva2U6IzAwMDtzdHJva2Utd2lk +dGg6Mi40N3B4OyIvPjxwYXRoIGQ9Ik00NzIuNTg0LDUzMS4wOTNjLTAsLTIuODI3IC0yLjI5NSwt +NS4xMjEgLTUuMTIxLC01LjEyMWwtMjkyLjU0MSwtMGMtMi44MjYsLTAgLTUuMTIxLDIuMjk0IC01 +LjEyMSw1LjEyMWwwLDEwLjI0M2MwLDIuODI2IDIuMjk1LDUuMTIxIDUuMTIxLDUuMTIxbDI5Mi41 +NDEsLTBjMi44MjYsLTAgNS4xMjEsLTIuMjk1IDUuMTIxLC01LjEyMWwtMCwtMTAuMjQzWiIgc3R5 +bGU9ImZpbGw6IzJjMmMyYztzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Mi40N3B4OyIvPjxnPjxw +YXRoIGQ9Ik0xOTAuMzc5LDQzMS40NTVjLTAsLTEuOTE2IC0xLjU1NiwtMy40NzIgLTMuNDcyLC0z +LjQ3MmwtNTQuNTYzLDBjLTEuOTE3LDAgLTMuNDcyLDEuNTU2IC0zLjQ3MiwzLjQ3MmwtMCw0Ni4z +MDljLTAsMS45MTYgMS41NTUsMy40NzIgMy40NzIsMy40NzJsNTQuNTYzLC0wYzEuOTE2LC0wIDMu +NDcyLC0xLjU1NiAzLjQ3MiwtMy40NzJsLTAsLTQ2LjMwOVoiIHN0eWxlPSJmaWxsOiNlZWFjMDE7 +c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjMuMXB4OyIvPjxnPjxwYXRoIGQ9Ik0xMzkuOTk2LDQ0 +Mi44NTNjLTAsMC40MiAtMC4wNzEsMC45MjIgMC4wNjMsMS4zMjNjMC41MywxLjU5MiAyLjc1LDEu +Mzg2IDQuMDk1LDEuMzg2YzIuMzk3LC0wIDQuNjc0LC0wLjEwOCA2LjQ4OSwtMS44MjdjMC40NSwt +MC40MjYgMC45NjYsLTAuOTQgMS4wNzIsLTEuNTc1YzAuMDYxLC0wLjM2NyAwLjEwMiwtMS4yMDgg +LTAuMTI2LC0xLjUxMmMtMC44MDMsLTEuMDcxIC0yLjg0NCwtMC4zNzMgLTMuNjU1LDAuMjUyYy0w +LjcxNCwwLjU1IC0wLjgyMiwxLjgyNiAtMC45NDUsMi42NDZjLTAuNDA2LDIuNzA1IDAuMzg3LDUu +MDMgMy4xNSw1Ljg1OWMzLjQwNSwxLjAyMSA2Ljc4NCwwLjA2IDEwLjA4NiwtMC44MDljMS41OTcs +LTAuNDIxIDMuNjYsLTAuNzcgNS4wMTcsLTEuNzJjMC40NTIsLTAuMzE2IDAuNDQ0LC0wLjI4NCAw +LjkwMSwtMC41NThjMS4yMDgsLTAuNzI1IDMuODY5LC0xLjkyNyAyLjQ1NywtMy41OTFjLTAuMTks +LTAuMjI0IC0xLjEzOSwtMC4yMzcgLTEuMjYsMC4xMjZjLTAuNDIsMS4yNTcgLTEuMzI2LDMuMzAy +IDAuNTY3LDMuNzhjMS4zOTgsMC4zNTMgMi45MTksMC4xODkgNC4zNDcsMC4xODljMC4xOTMsLTAg +MS40ODUsLTAuMjc5IDEuNzAxLC0wLjA2M2MwLjU1NSwwLjU1NSAxLjU0MywwLjgxMSAyLjMzMSwx +LjAwOGMwLjkzMiwwLjIzMyAxLjk0NCwwLjYwNiAyLjg5OSwwLjY5M2MwLjg2OSwwLjA3OSAxLjc5 +LC0wLjA0NSAyLjY0NiwwLjEyNiIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Ut +d2lkdGg6MS45NnB4OyIvPjxwYXRoIGQ9Ik0xNDIuNzA1LDQ1Mi42ODFjMC4xMTksMC41NTEgMC44 +OTMsMC42NjkgMS4zMTgsMC44NDZjMS41OTgsMC42NjYgMy41MTQsMC44NzQgNS4yMzQsMC45ODJj +Mi4zNzgsMC4xNDggNC43MDQsLTAuMTU5IDcuMDU3LC0wLjMxNWMxLjg2MSwtMC4xMjUgMy43NDYs +MC4xMTYgNS42MDcsLTBjMi4wNTEsLTAuMTI5IDQuMzIxLC0wLjM3NyA2LjM2NCwtMC4wNjNjMS4w +NzIsMC4xNjQgMi4wODksMC40OCAzLjE1LDAuNjkzIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZToj +MDAwO3N0cm9rZS13aWR0aDoxLjk2cHg7Ii8+PHBhdGggZD0iTTE3Ni4zNDksNDU1LjA3NmMxLjEx +MywtMC4wMjEgMi4yMjYsLTAuMDQyIDMuMzQsLTAuMDYzIiBzdHlsZT0iZmlsbDpub25lO3N0cm9r +ZTojMDAwO3N0cm9rZS13aWR0aDoxLjk2cHg7Ii8+PHBhdGggZD0iTTE0NS4xNjIsNDU5Ljg2NGwy +MS42NzQsLTAiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjEuOTZw +eDsiLz48L2c+PC9nPjxnPjxnPjxwYXRoIGQ9Ik03Ni4yNzMsMTIxLjQ5NGw0ODcuNTU3LC0wLjAx +NSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6I2Y0ZGRkMDtzdHJva2Utd2lkdGg6NC45M3B4O3N0 +cm9rZS1saW5lY2FwOnNxdWFyZTsiLz48cGF0aCBkPSJNMjMwLjk1MSwyMjQuMTc1Yy0wLjgwMiwt +My4zMjcgLTguMDU5LC03Ljk4NCAtMTEuMDA1LC04Ljk0NWMtMzUuODQ2LC0xMS42OTkgLTIwLjc4 +MywyNy4zNzIgLTAuNjE4LDI0LjM0MiIgc3R5bGU9ImZpbGw6IzYxZTBlZTsiLz48cGF0aCBkPSJN +MjM0Ljk0NCwyMjMuMjEzYy0wLjM3OSwtMS41NzQgLTEuNTIyLC0zLjQzNiAtMy4yNzYsLTUuMTc0 +Yy0zLjA2MSwtMy4wMzMgLTguMDE0LC01LjkyIC0xMC40NDgsLTYuNzE0Yy0xMy4xMzUsLTQuMjg3 +IC0yMC42MTgsLTEuOTk0IC0yNC4wNzEsMS44MTljLTMuNjM4LDQuMDE3IC0zLjgzNiwxMC40OTIg +LTAuODI1LDE2LjY1MWMzLjk4MSw4LjE0MyAxMy4zMjEsMTUuMzg2IDIzLjYxNSwxMy44MzljMi4y +NDIsLTAuMzM3IDMuNzg4LC0yLjQzIDMuNDUxLC00LjY3MmMtMC4zMzcsLTIuMjQyIC0yLjQzLC0z +Ljc4OSAtNC42NzIsLTMuNDUyYy02LjY0NywwLjk5OSAtMTIuNDQ0LC00LjA2NSAtMTUuMDE0LC05 +LjMyM2MtMC44MDEsLTEuNjM3IC0xLjI5MywtMy4zMDggLTEuMzAyLC00Ljg0N2MtMC4wMDUsLTEu +MDIgMC4xOTUsLTEuOTc0IDAuODM2LC0yLjY4MmMwLjgzMiwtMC45MTggMi4yMjUsLTEuMzU0IDQu +MTMzLC0xLjQ4MWMyLjg1MiwtMC4xOTEgNi41NjcsMC40MTIgMTEuMywxLjk1N2MxLjQ1NiwwLjQ3 +NSA0LjE2LDIuMDk2IDYuMjU3LDMuODY5YzAuNjM2LDAuNTM3IDEuMjE1LDEuMDg4IDEuNjU4LDEu +NjM2YzAuMTUyLDAuMTg4IDAuMzMxLDAuMzI3IDAuMzcyLDAuNDk4YzAuNTMxLDIuMjA0IDIuNzUx +LDMuNTYyIDQuOTU1LDMuMDMxYzIuMjA0LC0wLjUzMSAzLjU2MiwtMi43NTIgMy4wMzEsLTQuOTU1 +WiIvPjxwYXRoIGQ9Ik0zNDkuMzI2LDEyMy41MjdjMCwwIC0xNS4zNSwtMTMuODYyIC0xOC4xMTQs +MjIuODM0IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojNjFlMGVlO3N0cm9rZS13aWR0aDo2LjE2 +cHg7Ii8+PHBhdGggZD0iTTI5OS42NjIsMTIzLjQ4NWMtMCwtMCAxMi42NzEsLTE1LjI2NSAyMC4x +MTIsMjEuODk4IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojNjFlMGVlO3N0cm9rZS13aWR0aDo2 +LjE2cHg7Ii8+PHBhdGggZD0iTTMzNi4xMTEsMTA4Ljc3M2MtMCwwIC0xNi44MDgsLTkuMDk5IC0x +MC44ODIsMzIuMTI0IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojNjFlMGVlO3N0cm9rZS13aWR0 +aDo2LjE2cHg7Ii8+PHBhdGggZD0iTTQxNy43OTYsMTg0LjQ1M2MzLjI4MiwwLjk3IDYuNTgzLC0x +LjI5IDguODg5LC0zLjM2MWMyOC4wNTUsLTI1LjE5NCAtMTMuMTk4LC0zMS41MzggLTIwLjY1Niwt +MTIuNTU5IiBzdHlsZT0iZmlsbDojNjFlMGVlOyIvPjxwYXRoIGQ9Ik00MTYuNjMyLDE4OC4zOTNj +NC42NTcsMS4zNzUgOS41MjYsLTEuMzA3IDEyLjc5NywtNC4yNDVjMTAuMjc5LC05LjIzIDEyLjA0 +NiwtMTYuODQzIDEwLjQ4OCwtMjEuNzE2Yy0xLjY0NSwtNS4xNSAtNy4xNDIsLTguNTQ0IC0xMy45 +NzgsLTguOTljLTkuMDExLC0wLjU4NyAtMTkuOTI3LDMuOTA0IC0yMy43MzMsMTMuNTg5Yy0wLjgz +LDIuMTEgMC4yMSw0LjQ5NiAyLjMyLDUuMzI1YzIuMTEsMC44MjkgNC40OTYsLTAuMjExIDUuMzI1 +LC0yLjMyMWMyLjQ2LC02LjI1OSA5LjczLC04Ljc3NSAxNS41NTMsLTguMzk1YzEuODA3LDAuMTE3 +IDMuNDg5LDAuNTE3IDQuODE3LDEuMjY5YzAuODcxLDAuNDk0IDEuNTg2LDEuMTMgMS44NzEsMi4w +MjNjMC4zNzUsMS4xNzIgMC4wNDksMi41ODggLTAuNzk1LDQuMjk4Yy0xLjI2NiwyLjU2MiAtMy42 +NDksNS40NzcgLTcuMzU2LDguODA2Yy0wLjg2OCwwLjc4IC0xLjkwNywxLjYxOCAtMy4wNTQsMi4x +NDdjLTAuNjE2LDAuMjg0IC0xLjI2NSwwLjUyNyAtMS45MjgsMC4zMzFjLTIuMTc0LC0wLjY0MiAt +NC40NjEsMC42MDIgLTUuMTAzLDIuNzc2Yy0wLjY0MiwyLjE3NCAwLjYwMiw0LjQ2MSAyLjc3Niw1 +LjEwM1oiLz48cGF0aCBkPSJNNDM2LjAxNCw0MTkuNjQ1YzMuNjAxLC0xMC45MzQgOS4wMDMsLTMx +LjYwOSA1LjY4MiwtNDkuNTYzYy0xNC45NTMsLTgwLjg0NyA3Ljg5NSwtOTguNTQ5IC01LjAzNSwt +MTQ3LjU5M2MtNC40OSwtMTcuMDI5IC0xMi4wNDksLTM2LjI1IC0yMy41NTQsLTQ5Ljc5MmMtMjUu +Mjg1LC0yOS43NjIgLTcwLjczNiwtNDIuOTEzIC0xMDguNzMsLTMyLjc4NWMtMTUuMjEzLDQuMDU1 +IC0yOC42NjIsMTEuNzI3IC00MS45MjMsMjAuMDM0Yy00Ni4xMzYsMjguOSAtNTIuMjU5LDgzLjM3 +NyAtNDIuNzkxLDEyOC4yNDhjNC4xOTEsMTkuODY0IDIzLjY3Myw0OS44OTcgMjIuNTg2LDcwLjE5 +NGMtMS4zODcsMjUuOTEgLTAuNzY1LDQ0LjQyNiAzLjM3Niw2MC44ODQiIHN0eWxlPSJmaWxsOiM2 +MWUwZWU7Ii8+PGNsaXBQYXRoIGlkPSJfY2xpcDEiPjxwYXRoIGQ9Ik00MzYuMDE0LDQxOS42NDVj +My42MDEsLTEwLjkzNCA5LjAwMywtMzEuNjA5IDUuNjgyLC00OS41NjNjLTE0Ljk1MywtODAuODQ3 +IDcuODk1LC05OC41NDkgLTUuMDM1LC0xNDcuNTkzYy00LjQ5LC0xNy4wMjkgLTEyLjA0OSwtMzYu +MjUgLTIzLjU1NCwtNDkuNzkyYy0yNS4yODUsLTI5Ljc2MiAtNzAuNzM2LC00Mi45MTMgLTEwOC43 +MywtMzIuNzg1Yy0xNS4yMTMsNC4wNTUgLTI4LjY2MiwxMS43MjcgLTQxLjkyMywyMC4wMzRjLTQ2 +LjEzNiwyOC45IC01Mi4yNTksODMuMzc3IC00Mi43OTEsMTI4LjI0OGM0LjE5MSwxOS44NjQgMjMu +NjczLDQ5Ljg5NyAyMi41ODYsNzAuMTk0Yy0xLjM4NywyNS45MSAtMC43NjUsNDQuNDI2IDMuMzc2 +LDYwLjg4NCIvPjwvY2xpcFBhdGg+PGcgY2xpcC1wYXRoPSJ1cmwoI19jbGlwMSkiPjxwYXRoIGQ9 +Ik0zMTEuNzc2LDIzNi44MzVjMCwwIC0zLjA2OCwyNC4xOTkgLTMxLjU5NywyNC44MzhjLTI4LjUz +LDAuNjM5IC0zMi41OTEsLTI4LjI0NiAtMzIuNTkxLC0yOC4yNDZsNjQuMTg4LDMuNDA4WiIgc3R5 +bGU9ImZpbGw6IzRmY2JlMDsiLz48cGF0aCBkPSJNNDMyLjUwOSwyMTYuMDIzYzAsLTAgLTMuMDY3 +LDI0LjE5OCAtMzEuNTk3LDI0LjgzN2MtMjguNTMsMC42MzkgLTMyLjU5MSwtMjguMjQ2IC0zMi41 +OTEsLTI4LjI0Nmw2NC4xODgsMy40MDlaIiBzdHlsZT0iZmlsbDojNGZjYmUwOyIvPjxwYXRoIGQ9 +Ik0yODYuODQ1LDQxOC45NjFsMTI3Ljg4NiwtMGMwLjM4NiwtMS4yNjcgMC42NjYsLTIuNDMyIDAu +ODcsLTMuNDcxYzMuODY3LC0xOS43NDMgMy43MjksLTU3LjQ2OCAtMi40MTUsLTc3LjA5NWMtMi4x +MzMsLTYuODE2IC01Ljc5MywtMTQuNDkgLTExLjQ3NiwtMTkuODUyYy0xMi40OSwtMTEuNzg1IC02 +NC4zOTgsLTkuMTgxIC04My41NjEsLTQuODM1Yy03LjY3MywxLjc0MSAtMTQuNDg3LDQuOTIyIC0y +MS4yMSw4LjM1OGMtMTQuOTExLDcuNjIgLTIwLjI1Myw1OC43MTIgLTEwLjA5NCw5Ni44OTVaIiBz +dHlsZT0iZmlsbDojYjdmOGZmOyIvPjxwYXRoIGQ9Ik0yNDYuNzExLDQxOC45NjFjLTUuMTA4LC0y +MC42MTMgLTYuNDksLTQyLjk3NSAtNS41NjIsLTYwLjg1NWMyLjEzNiwtNDEuMTMzIC0zMS4zMTQs +LTUwLjQ1MyAtMjUuNDYxLC0xMjEuNTEzYzE0LjUzNCwtNDIuNDA5IDE1LjA4OSwtNDAuMTA3IDI3 +LjY1MywtNTUuNDQ1YzAsMCAtMTMuMTQ3LDM5Ljk2OCAtMS4xOTMsNzAuNzI4YzExLjk1NCwzMC43 +NTkgMzIuMTc0LDczLjQxOCAzMi41OSwxMTcuNDkxYzAuMTg2LDE5Ljc3MSAxLjYzNCwzNi4zMSA0 +LjEzLDQ5LjU5NGwtMzIuMTU3LC0wWiIgc3R5bGU9ImZpbGw6IzRmY2JlMDsiLz48L2c+PHBhdGgg +ZD0iTTQzOS45MTUsNDIwLjkzYzMuNzQ4LC0xMS4zOCA5LjI3NiwtMzIuOTA4IDUuODIsLTUxLjU5 +NWMtOC44NjUsLTQ3LjkzMyAtNC4yNTksLTczLjQwNSAtMS45MzUsLTk2LjU4YzEuNjEzLC0xNi4w +OTMgMi4xNTYsLTMxLjEyMiAtMy4xNjcsLTUxLjMxM2MtNC42MzgsLTE3LjU5MSAtMTIuNTExLC0z +Ny40MTYgLTI0LjM5NiwtNTEuNDA1Yy0yNi4yNjEsLTMwLjkxMSAtNzMuNDU2LC00NC42MTMgLTEx +Mi45MTcsLTM0LjA5NGMtMTUuNjE0LDQuMTYxIC0yOS40MzcsMTEuOTk2IC00My4wNDcsMjAuNTIy +Yy00Ny43MzUsMjkuOTAyIC01NC40MjYsODYuMTUgLTQ0LjYyOSwxMzIuNTc3YzIuMTM5LDEwLjEz +OCA4LjEzNiwyMi44ODggMTMuNTA0LDM1LjY3NmM0Ljk5NCwxMS45IDkuNTE1LDIzLjgxIDguOTk5 +LDMzLjQ1Yy0xLjQxNSwyNi40MzIgLTAuNzMsNDUuMzE3IDMuNDk1LDYyLjEwN2wxLjAwMiwzLjk4 +M2w3Ljk2NywtMi4wMDVsLTEuMDAyLC0zLjk4M2MtNC4wNTgsLTE2LjEyNyAtNC42MTgsLTM0LjI3 +NCAtMy4yNTgsLTU5LjY2M2MwLjQ2LC04LjYwOSAtMi40NiwtMTguODk1IC02LjU0NSwtMjkuNDU3 +Yy01LjY5NCwtMTQuNzI0IC0xMy42NDYsLTMwLjA1OSAtMTYuMTI0LC00MS44MDRjLTkuMTQsLTQz +LjMxNSAtMy41ODQsLTk2LjAyMiA0MC45NTIsLTEyMy45MTljMTIuOTEyLC04LjA4OSAyNS45ODks +LTE1LjU5OCA0MC44MDEsLTE5LjU0N2MzNi41MjYsLTkuNzM1IDgwLjIzNCwyLjg2NSAxMDQuNTQx +LDMxLjQ3NmMxMS4xMjYsMTMuMDk2IDE4LjM3MiwzMS43MTMgMjIuNzEzLDQ4LjE4YzYuMzEsMjMu +OTMyIDMuODIsNDAuMjE5IDEuNjQ3LDYwLjM4M2MtMi4yNTcsMjAuOTQ5IC00LjI1OSw0NS45Mjcg +My4zMjEsODYuOTFjMy4xODYsMTcuMjIyIC0yLjA5LDM3LjA0MyAtNS41NDQsNDcuNTMxbC0xLjI4 +NSwzLjkwMmw3LjgwMywyLjU2OWwxLjI4NCwtMy45MDFaIi8+PHBhdGggZD0iTTQyNC41MzksMjQy +LjI0N2MtMS44NjgsMC4xMzQgLTMuMjc2LDEuNzU4IC0zLjE0MiwzLjYyNmMwLjEzMywxLjg2NyAx +Ljc1NywzLjI3NSAzLjYyNSwzLjE0MWMxLjg2NywtMC4xMzMgMy4yNzUsLTEuNzU3IDMuMTQyLC0z +LjYyNWMtMC4xMzQsLTEuODY3IC0xLjc1OCwtMy4yNzUgLTMuNjI1LC0zLjE0MloiIHN0eWxlPSJm +aWxsOiNiN2Y4ZmY7Ii8+PHBhdGggZD0iTTI1Ni45OTcsMjYwLjM5MWMtMS44NjgsMC4xMzMgLTMu +Mjc1LDEuNzU4IC0zLjE0MiwzLjYyNWMwLjEzMywxLjg2OCAxLjc1OCwzLjI3NSAzLjYyNSwzLjE0 +MmMxLjg2OCwtMC4xMzMgMy4yNzUsLTEuNzU4IDMuMTQyLC0zLjYyNWMtMC4xMzMsLTEuODY4IC0x +Ljc1OCwtMy4yNzUgLTMuNjI1LC0zLjE0MloiIHN0eWxlPSJmaWxsOiNiN2Y4ZmY7Ii8+PGc+PHBh +dGggZD0iTTM2NC4wOSwyNjkuMjI5Yy0xLjE4Niw0Ljk2NyAtMS40NDMsMTQuMzA2IC00LjMxNSwx +OC40Yy0xLjM5MywxLjk4NSAtNC40OTUsMi4wODYgLTYuNTE2LDIuMTUzYy0xMS42MTIsMC4zODMg +LTExLjQ0NiwtMTEuNTEzIC0xMy4xODEsLTIxLjg1NSIgc3R5bGU9ImZpbGw6I2VkZWRlZDsiLz48 +cGF0aCBkPSJNMzYxLjA5NCwyNjguNTEzYy0wLjcxOSwzLjAwOSAtMS4xMDYsNy42MDcgLTEuOTIy +LDExLjY5OGMtMC40NDUsMi4yMjggLTAuOTcxLDQuMjk4IC0xLjkxOSw1LjY0OWMtMC4yNjMsMC4z +NzYgLTAuNzQ5LDAuNDU5IC0xLjIwMywwLjU2NmMtMC45OTUsMC4yMzMgLTIuMDU2LDAuMjUgLTIu +ODkzLDAuMjc3Yy0yLjA1NSwwLjA2OCAtMy42MSwtMC4zMjYgLTQuNzc0LC0xLjE5MmMtMS4xOTMs +LTAuODg3IC0xLjk1NCwtMi4yMTIgLTIuNTQ0LC0zLjc0M2MtMS41NjEsLTQuMDQ3IC0xLjg5LC05 +LjM5IC0yLjcyMywtMTQuMzUxYy0wLjI4MSwtMS42NzcgLTEuODcxLC0yLjgxIC0zLjU0OCwtMi41 +MjhjLTEuNjc3LDAuMjgxIC0yLjgwOSwxLjg3MSAtMi41MjgsMy41NDhjMC45MDMsNS4zODEgMS4z +NTgsMTEuMTU4IDMuMDUxLDE1LjU0OGMxLjAzNiwyLjY4OCAyLjUyLDQuOTEyIDQuNjE1LDYuNDdj +Mi4xMjUsMS41ODEgNC45MDMsMi41MyA4LjY1NCwyLjQwNmMxLjQ2OCwtMC4wNDggMy40LC0wLjE1 +MiA1LjA3OSwtMC43MTRjMS41NiwtMC41MjIgMi45MTksLTEuNDEgMy44NTgsLTIuNzQ5YzEuMzUs +LTEuOTI0IDIuMjg0LC00LjgwOCAyLjkxNywtNy45ODFjMC44LC00LjAxMSAxLjE2OCwtOC41MjIg +MS44NzMsLTExLjQ3M2MwLjM5NSwtMS42NTMgLTAuNjI3LC0zLjMxNyAtMi4yODEsLTMuNzEyYy0x +LjY1NCwtMC4zOTUgLTMuMzE3LDAuNjI3IC0zLjcxMiwyLjI4MVoiLz48cGF0aCBkPSJNMzQ5LjAw +MSwyMzQuMDVjLTkuMzMyLDEuMjQ2IC0xNi4yNjUsNy4wNzUgLTE1LjQ3MywxMy4wMDhjMC43OTIs +NS45MzMgOS4wMTIsOS43MzggMTguMzQ0LDguNDkyYzkuMzMzLC0xLjI0NiAxNi4yNjYsLTcuMDc1 +IDE1LjQ3NCwtMTMuMDA4Yy0wLjc5MiwtNS45MzMgLTkuMDEyLC05LjczOCAtMTguMzQ1LC04LjQ5 +MloiLz48cGF0aCBkPSJNMzQ4LjkwMSwyNTIuNTVjMC40Niw1LjIwNCAxLjY5NCwxMC4xNDIgMi40 +NTMsMTUuMjc3YzAuMjQ4LDEuNjgyIDEuODE1LDIuODQ2IDMuNDk3LDIuNTk4YzEuNjgyLC0wLjI0 +OCAyLjg0NiwtMS44MTYgMi41OTgsLTMuNDk4Yy0wLjc0MSwtNS4wMTUgLTEuOTYxLC05LjgzNyAt +Mi40MTEsLTE0LjkyYy0wLjE1LC0xLjY5NCAtMS42NDcsLTIuOTQ3IC0zLjM0LC0yLjc5N2MtMS42 +OTQsMC4xNSAtMi45NDcsMS42NDcgLTIuNzk3LDMuMzRaIi8+PHBhdGggZD0iTTM3NC4yNiwyNTku +ODE0Yy04LjAyMiw1LjM1IC0xNS45MDEsNy44MjUgLTIzLjU5NCw3LjI3NGMtNy42OTQsLTAuNTUx +IC0xNS4xNDgsLTQuMTI0IC0yMi4zNDIsLTEwLjU4OWMtMS4yNjUsLTEuMTM2IC0zLjIxNCwtMS4w +MzIgLTQuMzUxLDAuMjMzYy0xLjEzNiwxLjI2NCAtMS4wMzIsMy4yMTQgMC4yMzMsNC4zNWM4LjM1 +Niw3LjUwOCAxNy4wODQsMTEuNTExIDI2LjAyLDEyLjE1MWM4LjkzNiwwLjY0IDE4LjEzNSwtMi4w +NzggMjcuNDUzLC04LjI5NGMxLjQxNCwtMC45NDMgMS43OTcsLTIuODU4IDAuODUzLC00LjI3MmMt +MC45NDMsLTEuNDE1IC0yLjg1OCwtMS43OTcgLTQuMjcyLC0wLjg1M1oiLz48cGF0aCBkPSJNMzc4 +LjYwNSwxNTIuMTU2Yy0zLjY0NywtMC44MjYgLTYuOTU2LDAuMDM4IC03LjM4NSwxLjkyOWMtMC40 +MjgsMS44OTEgMi4xODUsNC4wOTcgNS44MzIsNC45MjRjMy42NDcsMC44MjcgNi45NTYsLTAuMDM3 +IDcuMzg1LC0xLjkyOGMwLjQyOCwtMS44OTEgLTIuMTg1LC00LjA5OCAtNS44MzIsLTQuOTI1WiIg +c3R5bGU9ImZpbGw6I2I3ZjhmZjsiLz48cGF0aCBkPSJNMjQ5LjUsMTc3LjI3Yy0yLjE5NSwzLjAy +OCAtMi43MDIsNi40MSAtMS4xMzIsNy41NDhjMS41NywxLjEzOCA0LjYyNiwtMC4zOTYgNi44MjEs +LTMuNDI0YzIuMTk1LC0zLjAyOCAyLjcwMiwtNi40MSAxLjEzMiwtNy41NDhjLTEuNTcsLTEuMTM4 +IC00LjYyNiwwLjM5NiAtNi44MjEsMy40MjRaIiBzdHlsZT0iZmlsbDojYjdmOGZmOyIvPjxwYXRo +IGQ9Ik00MDMuNjQ4LDE2Mi44MjVjLTE5LjIxMywtMS4zNSAtMzUuOTA3LDEzLjE1MSAtMzcuMjU3 +LDMyLjM2NGMtMS4zNSwxOS4yMTIgMTMuMTUyLDM1LjkwNiAzMi4zNjQsMzcuMjU2YzE5LjIxMywx +LjM1MSAzNS45MDcsLTEzLjE1MSAzNy4yNTcsLTMyLjM2NGMxLjM1LC0xOS4yMTIgLTEzLjE1Miwt +MzUuOTA2IC0zMi4zNjQsLTM3LjI1NloiIHN0eWxlPSJmaWxsOiNmZmY7c3Ryb2tlOiMwMDA7c3Ry +b2tlLXdpZHRoOjYuMTZweDsiLz48cGF0aCBkPSJNMjgxLjY5NiwxODEuNzhjLTE5LjIxMiwtMS4z +NSAtMzUuOTA2LDEzLjE1MiAtMzcuMjU3LDMyLjM2NGMtMS4zNSwxOS4yMTIgMTMuMTUyLDM1Ljkw +NyAzMi4zNjUsMzcuMjU3YzE5LjIxMiwxLjM1IDM1LjkwNiwtMTMuMTUyIDM3LjI1NiwtMzIuMzY0 +YzEuMzUsLTE5LjIxMyAtMTMuMTUyLC0zNS45MDcgLTMyLjM2NCwtMzcuMjU3WiIgc3R5bGU9ImZp +bGw6I2ZmZjtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Ni4xNnB4OyIvPjxwYXRoIGQ9Ik0zOTgu +NDk5LDE5Mi44ODFjLTYuODI0LC0wLjQ3OSAtMTIuNzU0LDQuNjcyIC0xMy4yMzMsMTEuNDk2Yy0w +LjQ4LDYuODI0IDQuNjcxLDEyLjc1MyAxMS40OTUsMTMuMjMzYzYuODI0LDAuNDc5IDEyLjc1NCwt +NC42NzIgMTMuMjMzLC0xMS40OTZjMC40OCwtNi44MjQgLTQuNjcxLC0xMi43NTMgLTExLjQ5NSwt +MTMuMjMzWiIvPjxwYXRoIGQ9Ik0yNzguMTQyLDIxMC4yMjdjLTYuODI0LC0wLjQ3OSAtMTIuNzU0 +LDQuNjcxIC0xMy4yMzMsMTEuNDk2Yy0wLjQ4LDYuODI0IDQuNjcxLDEyLjc1MyAxMS40OTUsMTMu +MjMzYzYuODI0LDAuNDc5IDEyLjc1NCwtNC42NzIgMTMuMjMzLC0xMS40OTZjMC40OCwtNi44MjQg +LTQuNjcxLC0xMi43NTMgLTExLjQ5NSwtMTMuMjMzWiIvPjxwYXRoIGQ9Ik0zOTkuODkyLDE5Ny45 +NjFjLTEuOTE2LC0wLjEzNSAtMy41ODEsMS4zMTEgLTMuNzE2LDMuMjI3Yy0wLjEzNCwxLjkxNiAx +LjMxMiwzLjU4MSAzLjIyOCwzLjcxNmMxLjkxNiwwLjEzNCAzLjU4MSwtMS4zMTIgMy43MTUsLTMu +MjI4YzAuMTM1LC0xLjkxNiAtMS4zMTEsLTMuNTgxIC0zLjIyNywtMy43MTVaIiBzdHlsZT0iZmls +bDojZmZmOyIvPjxwYXRoIGQ9Ik0yNzkuNTM1LDIxNS4zMDZjLTEuOTE2LC0wLjEzNCAtMy41ODEs +MS4zMTIgLTMuNzE1LDMuMjI4Yy0wLjEzNSwxLjkxNiAxLjMxMSwzLjU4MSAzLjIyNywzLjcxNmMx +LjkxNiwwLjEzNCAzLjU4MSwtMS4zMTIgMy43MTYsLTMuMjI4YzAuMTM0LC0xLjkxNiAtMS4zMTIs +LTMuNTgxIC0zLjIyOCwtMy43MTZaIiBzdHlsZT0iZmlsbDojZmZmOyIvPjwvZz48L2c+PHBhdGgg +ZD0iTTUxMi4yOTEsMzMzLjcyNmMwLC0wIC0yNy40OTYsMTMuNDAyIC00My4yNDUsMy43OTJjLTE1 +Ljc1LC05LjYxIC0xMC44LC0yNC43ODggMy4xMDIsLTIyLjUyYzEzLjkwMywyLjI2OCAyNy4xNjgs +My4xOTUgMzUuOTQzLC0zLjkzN2M4Ljc3NiwtNy4xMzIgMjEuNjMzLDE0LjM3NiA0LjIsMjIuNjY1 +WiIgc3R5bGU9ImZpbGw6I2Q2YzI5NTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Ni4xNnB4OyIv +PjwvZz48Zz48cGF0aCBkPSJNNDQxLjI2OCwyOTEuMzQ5Yy0wLDAgMjguMDgsNDcuNDg4IC0xMjIu +NTkxLDExNC4wNGMtMTEuNjI5LC0yLjAxNyAtMTQuMzMsLTguNTE1IC0xNC4zMywtOC41MTVjMCwt +MCA0My42MTcsLTEyLjU4NiA4My44MTIsLTQ0Ljg3NmM0My41OTcsLTM1LjAyNCA1My4xMDksLTYw +LjY0OSA1My4xMDksLTYwLjY0OVoiIHN0eWxlPSJmaWxsOiM3YjQ3MjI7c3Ryb2tlOiMwMDA7c3Ry +b2tlLXdpZHRoOjYuNjhweDsiLz48cGF0aCBkPSJNMjMzLjk1NywzNzIuMTM4YzMuNTYsLTUuNTA2 +IDEwLjcyOSwtNy4zOTIgMTYuNTM4LC00LjM1MWMxNy43NjcsOS4yMzUgNTMuNjE0LDI4LjAzMiA2 +Ny4xNTQsMzYuMzc2YzUuOTcxLDMuNjggLTYuNDc3LDE2LjkxMyAtMTkuMjgzLDM3LjkyN2MtMy4z +NjgsNS41NjcgLTEwLjQzNiw3LjYyOCAtMTYuMjY5LDQuNzQ2Yy0yMS42NTYsLTEwLjcxNiAtNDEu +MDQ3LC0yMC45NTggLTU0LjIzNCwtMjguMDczYy00LjU5MiwtMi41MDEgLTcuOTM0LC02LjgwMiAt +OS4yMjMsLTExLjg2OWMtMS4yODgsLTUuMDY4IC0wLjQwNywtMTAuNDQzIDIuNDMzLC0xNC44MzNj +NC4zMzUsLTYuNzA4IDkuMTYyLC0xNC4xNyAxMi44ODQsLTE5LjkyM1oiIHN0eWxlPSJmaWxsOiM3 +YjQ3MjI7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48cGF0aCBkPSJNMjM5Ljg1 +LDM2MS4xNzFsLTI1Ljg0OCwyMy4yOTZsNzkuMTcsNDUuNjA1bDI1LjE5NiwtMjQuMjk1bC03OC41 +MTgsLTQ0LjYwNloiIHN0eWxlPSJmaWxsOiNhZDZjNGE7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRo +OjQuOTVweDsiLz48cGF0aCBkPSJNMjM4LjA4MSwzNzcuNzA0YzIuNTI2LDAuNTMgNC4xNDcsMy4w +MTEgMy42MTgsNS41MzdjLTAuNTMsMi41MjYgLTMuMDExLDQuMTQ3IC01LjUzOCwzLjYxOGMtMi41 +MjYsLTAuNTMgLTQuMTQ3LC0zLjAxMSAtMy42MTcsLTUuNTM4YzAuNTMsLTIuNTI2IDMuMDExLC00 +LjE0NyA1LjUzNywtMy42MTdaIiBzdHlsZT0iZmlsbDojZmZiMjVjO3N0cm9rZTojMDAwO3N0cm9r +ZS13aWR0aDo0Ljk1cHg7Ii8+PHBhdGggZD0iTTI5MC4zMyw0MDcuOWMyLjUyNywwLjUzIDQuMTQ3 +LDMuMDExIDMuNjE4LDUuNTM3Yy0wLjUzLDIuNTI3IC0zLjAxMSw0LjE0NyAtNS41MzcsMy42MThj +LTIuNTI3LC0wLjUzIC00LjE0OCwtMy4wMTEgLTMuNjE4LC01LjUzN2MwLjUzLC0yLjUyNyAzLjAx +MSwtNC4xNDggNS41MzcsLTMuNjE4WiIgc3R5bGU9ImZpbGw6I2ZmYjI1YztzdHJva2U6IzAwMDtz +dHJva2Utd2lkdGg6NC45NXB4OyIvPjwvZz48Zz48cGF0aCBkPSJNMzA1LjMyMywzMzYuMjM1YzAs +MCAyNC4yMDQsMTEyLjU3OCAzNS4zNTcsMTE0LjM1NWMxMS4xNTMsMS43NzYgMTgyLjk5OCwtNTcu +MTYyIDE4Mi45OTgsLTU3LjE2MmMwLC0wIC0yNC4yNywtMTA1Ljc3MyAtMzQuNDU2LC0xMTQuNjAy +Yy0xMC4xODYsLTguODMgLTE4My44OTksNTcuNDA5IC0xODMuODk5LDU3LjQwOVoiIHN0eWxlPSJm +aWxsOiNmZmYyZWI7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuNjhweDsiLz48cGF0aCBkPSJN +NDEzLjU2MywzNjcuNjU2bC0xMDguMjQsLTMxLjQyMWwxMDguMjQsMzEuNDIxWiIgc3R5bGU9ImZp +bGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6NS40NnB4OyIvPjxwYXRoIGQ9Ik00ODgu +NTY2LDI3OS4xNTZsLTc1LjAwMyw4OC41IiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0 +cm9rZS13aWR0aDo1LjQ2cHg7Ii8+PHBhdGggZD0iTTMzOS4xNjcsNDQ5LjEyN2w2Mi40MDEsLTgz +LjUwNSIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6NS40NnB4OyIv +PjxwYXRoIGQ9Ik00MjMuMjcsMzU1LjgzNWw5OS4xOTQsMzcuMDAxIiBzdHlsZT0iZmlsbDpub25l +O3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo1LjQ2cHg7Ii8+PC9nPjxwYXRoIGQ9Ik0zMDQuNDE2 +LDM3NC43ODVjLTAsMCAtMzAuNDYsLTIuOCAtMzguOTQzLC0xOS4xODVjLTguNDgyLC0xNi4zODQg +My42MjIsLTI2Ljc5NCAxNC4zMzIsLTE3LjY0NmMxMC43MTEsOS4xNDkgMjEuNTcyLDE2LjgyMSAz +Mi43NzQsMTUuMjc0YzExLjIwMSwtMS41NDcgMTEuMDQsMjMuNTExIC04LjE2MywyMS41NTdaIiBz +dHlsZT0iZmlsbDojZDZjMjk1O3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo2LjE2cHg7Ii8+PHBh +dGggZD0iTTEwOS45NTEsMzExLjcxMmw2OC4wNzYsLTAiIHN0eWxlPSJmaWxsOm5vbmU7c3Ryb2tl +OiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48cGF0aCBkPSJNMTM5LjMwNSwzNTEuNjgzbDY4 +LjA3NiwwIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDo0Ljk1cHg7 +Ii8+PHBhdGggZD0iTTEzOS42MTcsMzMxLjY5OGwzOC4wOTgsLTAiIHN0eWxlPSJmaWxsOm5vbmU7 +c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48cGF0aCBkPSJNODUuMjAxLDIxMS4y +NTNjLTAsLTIuMjEzIC0xLjc5NywtNC4wMSAtNC4wMSwtNC4wMWwtNTMuNDg3LC0wYy0yLjIxNCwt +MCAtNC4wMTEsMS43OTcgLTQuMDExLDQuMDFsMCw1OC44OTRjMCwyLjIxMyAxLjc5Nyw0LjAxIDQu +MDExLDQuMDFsNTMuNDg3LDBjMi4yMTMsMCA0LjAxLC0xLjc5NyA0LjAxLC00LjAxbC0wLC01OC44 +OTRaIiBzdHlsZT0iZmlsbDojZWVhYzAxO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDozLjQzcHg7 +Ii8+PHBhdGggZD0iTTU2NC42NDMsNDI2LjU5MWMwLC0yLjIxMyAtMS43OTYsLTQuMDEgLTQuMDEs +LTQuMDFsLTUzLjQ4NywtMGMtMi4yMTMsLTAgLTQuMDEsMS43OTcgLTQuMDEsNC4wMWwwLDU4Ljg5 +NGMwLDIuMjEzIDEuNzk3LDQuMDEgNC4wMSw0LjAxbDUzLjQ4NywwYzIuMjE0LDAgNC4wMSwtMS43 +OTcgNC4wMSwtNC4wMWwwLC01OC44OTRaIiBzdHlsZT0iZmlsbDojZWVhYzAxO3N0cm9rZTojMDAw +O3N0cm9rZS13aWR0aDozLjQzcHg7Ii8+PGNpcmNsZSBjeD0iNTE3LjAxNCIgY3k9IjEwOC43NzEi +IHI9IjQuMzUyIiBzdHlsZT0iZmlsbDojZDNkMmIzOyIvPjxjaXJjbGUgY3g9IjUzMS4zMiIgY3k9 +IjEwOC43NzEiIHI9IjQuMzUyIiBzdHlsZT0iZmlsbDojZjRiMzAzOyIvPjxjaXJjbGUgY3g9IjU0 +NC43OTIiIGN5PSIxMDguNzcxIiByPSI0LjM1MiIgc3R5bGU9ImZpbGw6I2Y5NjEzZDsiLz48cGF0 +aCBkPSJNMTA1LjI1Nyw0ODQuNDNjLTAsMCAyMC4zNzQsLTI1LjIyNSA0MS43MTksLTcuNzYxYzIx +LjM0NCwxNy40NjMgLTEyLjYxMyw1MC40NSAtNDAuNzQ5LDQyLjY4OSIgc3R5bGU9ImZpbGw6bm9u +ZTtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6OS45cHg7Ii8+PHBhdGggZD0iTTExMy4wMTksNDYw +LjE0MWMtMCwtNC4yNjUgLTMuNDYzLC03LjcyNyAtNy43MjgsLTcuNzI3bC03OC42NTUsLTBjLTQu +MjY1LC0wIC03LjcyNywzLjQ2MiAtNy43MjcsNy43MjdsLTAsNzguNjU1Yy0wLDQuMjY1IDMuNDYy +LDcuNzI4IDcuNzI3LDcuNzI4bDc4LjY1NSwtMGM0LjI2NSwtMCA3LjcyOCwtMy40NjMgNy43Mjgs +LTcuNzI4bC0wLC03OC42NTVaIiBzdHlsZT0iZmlsbDojNjFlMGVlO3N0cm9rZTojMDAwO3N0cm9r +ZS13aWR0aDo0LjU1cHg7c3Ryb2tlLWxpbmVjYXA6c3F1YXJlOyIvPjxwYXRoIGQ9Ik03Ny4yNzcs +Mzk1LjQwMmMtMi4yMzQsOC43NTUgLTYuNDk2LDE1LjM2NiAtMTEuNjQ5LDE3LjcxNGMxLjk5NSwz +LjA1NyAzLjE2Nyw2Ljc4MSAzLjE2NywxMC43OTdjMCwxMC4zODcgLTcuODQsMTguODIgLTE3LjQ5 +OCwxOC44MmMtOS42NTcsMCAtMTcuNDk4LC04LjQzMyAtMTcuNDk4LC0xOC44MmMwLC05LjczMyA2 +Ljg4NCwtMTcuNzUxIDE1LjY5NywtMTguNzIyYy0zLjM5OSwtNS45NiAtNS41MjQsLTE0LjQyMyAt +NS41MjQsLTIzLjgwNWMwLC0xOC4wMjQgNy44NDEsLTMyLjY1NyAxNy40OTksLTMyLjY1N2M2Ljc5 +MiwtMCAxMi42ODUsNy4yMzggMTUuNTg0LDE3LjgwMmMxLjYyOSwtMC41OTIgMy4zODcsLTAuOTE1 +IDUuMjIsLTAuOTE1YzguNDUsMCAxNS4zMTEsNi44NjEgMTUuMzExLDE1LjMxMWMtMCw4LjQ1IC02 +Ljg2MSwxNS4zMTEgLTE1LjMxMSwxNS4zMTFjLTEuNzUsMCAtMy40MzIsLTAuMjk0IC00Ljk5OCwt +MC44MzZaIiBzdHlsZT0iZmlsbDojZmZmO2ZpbGwtb3BhY2l0eTowLjU7Ii8+PHBhdGggZD0iTTMz +LjU4MywyMzMuOTNjMi41MjksLTAuMzM3IDQuNTEsLTEuMTc4IDYuNzE2LC0yLjQwM2MwLjk1Nywt +MC41MzIgMS45NjksLTAuOTA0IDIuODk4LC0xLjQ4NWMwLjc4OSwtMC40OTMgMS4zNDMsLTAuOTc2 +IDEuMzQzLC0xLjk3OWMwLC0wLjIzNiAwLjIyNCwtMC42MzIgMCwtMC43MDdjLTAuOTUyLC0wLjMx +NyAtMi4zNjQsMC40NjUgLTMuMDQsMC45OWMtMC42MjUsMC40ODYgLTEuNDk0LDAuNTc5IC0yLjEy +LDEuMDZjLTEuMDM2LDAuNzk2IC0xLjY4MiwyLjQ2MyAtMS43NjcsMy43NDZjLTAuMDYsMC44OTYg +LTAuMzU3LDIuODQgMC41NjUsMy4zOTNjMC4zNTcsMC4yMTQgMC45MjksMC4xNDIgMS4zNDMsMC4x +NDJjMS4wOCwtMCAyLjEwNCwwLjAxMiAzLjE4MSwtMC4xNDJjMi4wODIsLTAuMjk3IDQuMTY1LC0x +LjQ1NSA2LjAwOSwtMi40MDNjMi4wMTQsLTEuMDM2IDQuMDEyLC0xLjkyMSA1LjU4NCwtMy42MDVj +MC42NDIsLTAuNjg4IDEuMDEsLTEuMjg2IDEuMjcyLC0yLjE5MWMwLjA1OSwtMC4yMDMgLTAuMDYs +LTAuNzE4IDAuMDcxLC0wLjg0OWMwLjEwOCwtMC4xMDggMC4wOTksMC4yMjYgMC4wNzEsMC4yODNj +LTAuMTg1LDAuMzY3IC0wLjQ2MiwwLjc0IC0wLjYzNywxLjEzMWMtMC4zMjQsMC43MjkgLTAuMzUz +LDEuNzU5IC0wLjM1MywyLjU0NWMwLDAuMTMyIC0wLjA3LDAuNTcxIDAsMC42MzZjMC4wNTUsMC4w +NTEgMC4zNjMsMC4xOTMgMC40MjQsMC4yMTJjMi4wMDIsMC42MzMgNC4zNDIsLTAuNTMyIDYuMDA5 +LC0xLjQ4NGMwLjY0MSwtMC4zNjcgMS4zNjMsLTAuODUgMi4wNiwtMS4xMTJjMC4wMjYsLTAuMDEg +MC42NzgsLTAuMTk3IDAuNjk2LC0wLjE2MWMwLjI4MiwwLjU2MyAwLjExMSwxLjUxMSAwLjIxMiwy +LjEyMWMwLjI4NSwxLjcwNyAyLjM2NywxLjYxNSAzLjY3NiwxLjQ4NGMwLjIwNywtMC4wMiAwLjM2 +MywtMC4yMTIgMC41NjYsLTAuMjEyIiBzdHlsZT0iZmlsbDpub25lO3N0cm9rZTojMDAwO3N0cm9r +ZS13aWR0aDoyLjc1cHg7Ii8+PHBhdGggZD0iTTM2LjQ4MiwyNDIuMzQyYzEuMzQ2LC0wIDIuNjgy +LC0wLjE1NCA0LjAyOSwtMC4yMTJjNC4zMzIsLTAuMTg5IDguNjcxLC0wLjE5NiAxMy4wMDYsLTAu +MjgzYzYuNTI1LC0wLjEzMSAxMy4wNTYsMC4wNzEgMTkuNTgxLDAuMDcxIiBzdHlsZT0iZmlsbDpu +b25lO3N0cm9rZTojMDAwO3N0cm9rZS13aWR0aDoyLjc1cHg7Ii8+PHBhdGggZD0iTTUxNi4wOSw0 +NDIuMDMxYy0wLDAuOTM2IC0wLjUzMiwxLjg5MiAtMC4xMjYsMi44MzljMC41MjYsMS4yMjggMi4z +NjMsMS4yNzUgMy40NjksMC45NDZjMC42MjgsLTAuMTg3IDEuMzE2LC0xLjE3NiAxLjcwMywtMS42 +NGMxLjAzOCwtMS4yNDcgMi40MzUsLTIuMzc1IDIuNzEyLC00LjAzN2MwLjA0MSwtMC4yNDggMC4x +OTQsLTAuOTM2IDAuMDYzLC0xLjE5OGMtMC43NjMsLTEuNTI1IC0zLjA1OSwtMS40NzggLTQuMTY4 +LC0wLjM2OWMtMC4xNjQsMC4xNjQgLTAuNDQ2LDAuMjI3IC0wLjU2MywwLjQzMmMtMC45OTQsMS43 +NDUgLTAuNDM5LDQuMzQxIDAuNDQyLDUuOTkyYzAuMzA0LDAuNTY5IDAuODI5LDEuMDQ4IDEuMzI0 +LDEuNDUxYzAuMzM5LDAuMjc1IDAuNzc1LDAuNzE2IDEuMTk5LDAuOTI4YzMuMjYyLDEuNjMxIDcu +Njg5LC0wLjEzMyAxMC41OTYsLTEuNzQ4YzEuOTUyLC0xLjA4NSA0LjEsLTIuMTUyIDQuMSwtNC42 +NjhjLTAsLTAuMzI1IDAuMDM3LC0wLjY5NCAtMC4wNjMsLTEuMDA5Yy0wLjM2MSwtMS4xMzMgLTIu +MzA3LC0xLjA0MyAtMy4xNTQsLTAuNjk0Yy0xLjE3OSwwLjQ4NiAtMi4zNzMsMS4zOTUgLTMuMDks +Mi40NmMtMS4xNzQsMS43NDIgLTEuMjA3LDQuMjgzIDAuNTY3LDUuNjE0YzEuMjU2LDAuOTQxIDIu +OTU4LDAuOTIzIDQuNDQ3LDAuNzY2YzIuMjQ3LC0wLjIzNyA0LjI1MywtMS4zMTMgNi4wMjMsLTIu +NjU5YzAuODc1LC0wLjY2NCAyLjA5NCwtMS4zMTYgMi42NDksLTIuMzMzYzAuMTczLC0wLjMxNiAw +LjIxNSwtMC43NDUgMC4zNzksLTEuMDczYzAuMDcxLC0wLjE0MyAtMC4xNTgsMC4yODUgLTAuMTg5 +LDAuNDQyYy0wLjA1NiwwLjI3NiAtMC4wOTQsMC44NzggLTAsMS4xMzVjMC4zNzYsMS4wMjggMS4y +MzMsMS41MDkgMi4yNywxLjcwM2MxLjM0NiwwLjI1MiAzLjAwOSwtMC4wMSA0LjM1MiwtMC4xODlj +MC45MzksLTAuMTI1IDEuODkyLC0wLjUxNyAxLjg5MiwwLjc1NyIgc3R5bGU9ImZpbGw6bm9uZTtz +dHJva2U6IzAwMDtzdHJva2Utd2lkdGg6Mi40NXB4OyIvPjxwYXRoIGQ9Ik01MTQuMjU5LDQ2MC4z +NTljMC4yNjYsMC4xMzQgMC4wOCwxLjM1NCAwLjU2NywxLjM4OGMwLjQ0MiwwLjAzMSAwLjg4NSww +LjA0IDEuMzI4LDAuMDVjMC41MTcsMC4wMTIgMS4xMDQsMC4wOTggMS42MzMsLTAuMDM0YzEuNjg4 +LC0wLjQyMiAzLjc5NiwtMS41OTggNC40ODIsLTMuMjk2YzAuMTg4LC0wLjQ2NyAwLjI0NiwtMS4w +OTQgMC4zMTUsLTEuNTc3YzAuMDc1LC0wLjUyNCAwLjM3OCwtMS43MTggLTAuMDYzLC0yLjE0NGMt +MC4wODgsLTAuMDg1IC0wLjYwNCwtMC4wNjMgLTAuNjk0LC0wLjA2M2MtMC42OTUsLTAgLTEuNzk2 +LDAuMzkyIC0yLjIwNywxLjAwOWMtMC4yNzMsMC40MDkgLTAuMTg5LDAuODUxIC0wLjE4OSwxLjMy +NGMtMCwwLjYyIC0wLjAzMywxLjIyNCAwLjEyNiwxLjgzYzAuMzYyLDEuMzc5IDIuMTExLDEuOTQx +IDMuMzQzLDIuMDE4YzIuNjk2LDAuMTY4IDUuNDQ3LC0wLjI0NSA4LjEzNiwtMGMyLjIwNywwLjIg +NC40NTQsMC43MTcgNi42ODYsMC41MDRjMS41OCwtMC4xNSAzLjE1NCwtMC41MjUgNC43MywtMC42 +M2MzLjM5NCwtMC4yMjYgNi43OTEsLTAuNTA1IDEwLjIxOCwtMC41MDUiIHN0eWxlPSJmaWxsOm5v +bmU7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjIuNDVweDsiLz48cGF0aCBkPSJNNTE5LjA1NCw0 +NzEuNjhsMCwwLjk0NmMwLDAuMTU1IC0wLDAuMjUxIDAuMDYzLDAuMzc5bDAsMC4xMjZjMC42MDIs +LTIuNDA3IDMuNzc1LC0yLjc4NCA1LjgxLC0yLjc4NGMwLjE1LC0wIDAuMjMyLDAuNDggMC4yNDUs +MC41NzZjMC4wNTUsMC4zODUgLTAuMTkyLDEuNDUgMC4zNzksMS42NGMwLjk4OSwwLjMzIDIuNDA2 +LC0wLjMxOCAzLjM0MywtMC41NjhjMC4yMzUsLTAuMDYyIDEuMDA5LC0wLjQ0MSAxLjE5OCwtMC4y +NTJjMC45NTQsMC45NTQgMi40MTIsMC41NjIgMy43MjEsMC42MzFjMy4zMzIsMC4xNzUgNi42Mjks +LTAuMTg5IDkuOTY2LC0wLjE4OWMxLjA1OCwtMCAyLjE2MiwwLjA4NyAzLjIxNywtMGMwLjA2OCwt +MC4wMDYgMS4wMDksLTAuMDI3IDEuMDA5LC0wLjI1MyIgc3R5bGU9ImZpbGw6bm9uZTtzdHJva2U6 +IzAwMDtzdHJva2Utd2lkdGg6Mi40NXB4OyIvPjwvZz48Zz48cGF0aCBkPSJNMzc0LjU3OCwxMTQu +NDU1Yy0wLjYxMywtNC4yMTYgLTQuNTMzLC03LjE0MiAtOC43NDksLTYuNTNsLTk5Ljk0NSwxNC41 +MTVjLTQuMjE2LDAuNjEzIC03LjE0Miw0LjUzMyAtNi41Myw4Ljc0OWwyLjIxOSwxNS4yNzhjMC42 +MTIsNC4yMTYgNC41MzIsNy4xNDIgOC43NDksNi41M2w5OS45NDUsLTE0LjUxNWM0LjIxNiwtMC42 +MTIgNy4xNDIsLTQuNTMzIDYuNTI5LC04Ljc0OWwtMi4yMTgsLTE1LjI3OFoiIHN0eWxlPSJmaWxs +OiM5ZGFjY2Q7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjUuMjFweDsiLz48cGF0aCBkPSJNMzEz +Ljg2OSwxMTcuNDE0YzAsMCA2Ni4xMDIsOS44NzggNzcuNTgyLC0yMy43MzNjMTEuNDgsLTMzLjYx +MSAtMTIuOTg3LC01MS44NzMgLTQxLjYzNywtMzkuOTIxYy0yOC42NSwxMS45NTIgLTU0Ljg1LDQ2 +LjE0MiAtNTQuODUsNDYuMTQyYzAsMCAtMzAuMDA1LC04LjEwOCAtNDEuMzE0LDUuMjIxYy0xMS4z +MDksMTMuMzMgLTE3LjE2NywyNi42NDYgLTIuNDM1LDMxLjUxOWMxNC43MzIsNC44NzMgNjIuNjU0 +LC0xOS4yMjggNjIuNjU0LC0xOS4yMjhaIiBzdHlsZT0iZmlsbDojNzM1ZDkzO3N0cm9rZTojMDAw +O3N0cm9rZS13aWR0aDo1LjkzcHg7Ii8+PHBhdGggZD0iTTM3NC4xNDgsMTM2Ljg3NmMtMCwwIDM4 +LjQ5MywtOS4zMzggMzYuNTYzLC0zLjU0OWMtMS45Myw1Ljc5IC0zNy42MzgsMjYuOTAxIC0xMTQu +ODMxLDE1Ljk2NmM3MC4zNDUsLTEwLjczMSA3OC4yNjgsLTEyLjQxNyA3OC4yNjgsLTEyLjQxN1oi +IHN0eWxlPSJmaWxsOiM1MDNhNzE7c3Ryb2tlOiMwMDA7c3Ryb2tlLXdpZHRoOjQuOTVweDsiLz48 +cGF0aCBkPSJNMzU3LjM0NiwxMjQuOTA2bC01NC4wMjEsOS42MTJsLTAsNS4zOTlsNTQuMDIxLC05 +LjYxMmwwLC01LjM5OVoiIHN0eWxlPSJmaWxsOiNmZmY7Ii8+PGNpcmNsZSBjeD0iMzAwLjE1IiBj +eT0iMTM3LjE5OCIgcj0iNC42NzciIHN0eWxlPSJmaWxsOiNmZmIyNWM7c3Ryb2tlOiMwMDA7c3Ry +b2tlLXdpZHRoOjQuOTVweDsiLz48Y2lyY2xlIGN4PSIzNjEuNDEiIGN5PSIxMjcuNTgxIiByPSI0 +LjI4MiIgc3R5bGU9ImZpbGw6I2ZmYjI1YztzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6NC45NXB4 +OyIvPjwvZz48L3N2Zz4= diff --git a/testdata/logo.svg.base64.license b/testdata/logo.svg.base64.license new file mode 100644 index 0000000..da2e7e7 --- /dev/null +++ b/testdata/logo.svg.base64.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2022 Maria Letta, the go-mail Team + +SPDX-License-Identifier: CC-BY-ND-4.0 diff --git a/testdata/logo.svg.license b/testdata/logo.svg.license new file mode 100644 index 0000000..da2e7e7 --- /dev/null +++ b/testdata/logo.svg.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2022 Maria Letta, the go-mail Team + +SPDX-License-Identifier: CC-BY-ND-4.0 diff --git a/testdata/tmp/.gitkeep b/testdata/tmp/.gitkeep new file mode 100644 index 0000000..e69de29