Merge branch 'main' into main

This commit is contained in:
Michael Fuchs 2024-10-30 15:48:34 +01:00 committed by GitHub
commit ee00ae2dee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 13060 additions and 6909 deletions

View file

@ -1,23 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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 ./...

221
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,221 @@
# 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@4081bf99e2866ebe428fc0477b69eb4fcda7220a # v4.4.0
with:
base-ref: ${{ github.event.pull_request.base.sha || 'main' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
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@8406f4f1edaffef38e9fb9c53eb292fc1d7684fa # master
timeout-minutes: 5
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

View file

@ -1,67 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
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

View file

@ -54,7 +54,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@ -65,7 +65,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 uses: github/codeql-action/autobuild@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@ -79,4 +79,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 uses: github/codeql-action/analyze@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0

View file

@ -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@5a2ce3f5b92ee19cbb1541a4984c76d921601d7c # v4.3.4

View file

@ -1,54 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
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

View file

@ -1,21 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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

View file

@ -1,45 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
with:
go-version: ${{ matrix.go }}
- name: Run Tests
run: |
go test -race -shuffle=on ./...

View file

@ -1,23 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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

View file

@ -67,7 +67,7 @@ jobs:
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab. # format to the repository Actions tab.
- name: "Upload artifact" - name: "Upload artifact"
uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: SARIF file name: SARIF file
path: results.sarif path: results.sarif
@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 uses: github/codeql-action/upload-sarif@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View file

@ -1,56 +0,0 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# 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@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2
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 }}

4
.gitignore vendored
View file

@ -56,4 +56,6 @@ crashlytics.properties
crashlytics-build.properties crashlytics-build.properties
fabric.properties fabric.properties
testdata ## Coverage data
coverage.coverprofile
coverage.html

83
auth.go
View file

@ -4,7 +4,11 @@
package mail package mail
import "errors" import (
"errors"
"fmt"
"strings"
)
// SMTPAuthType is a type wrapper for a string type. It represents the type of SMTP authentication // SMTPAuthType is a type wrapper for a string type. It represents the type of SMTP authentication
// mechanism to be used. // mechanism to be used.
@ -35,7 +39,7 @@ const (
// IETF draft. The IETF draft is more lax than the MS spec, therefore we follow the I-D, which // IETF draft. The IETF draft is more lax than the MS spec, therefore we follow the I-D, which
// automatically matches the MS spec. // automatically matches the MS spec.
// //
// Since the "LOGIN" SASL authentication mechansim transmits the username and password in // Since the "LOGIN" SASL authentication mechanism transmits the username and password in
// plaintext over the internet connection, we only allow this mechanism over a TLS secured // plaintext over the internet connection, we only allow this mechanism over a TLS secured
// connection. // connection.
// //
@ -44,20 +48,53 @@ const (
// https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00 // https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
SMTPAuthLogin SMTPAuthType = "LOGIN" SMTPAuthLogin SMTPAuthType = "LOGIN"
// SMTPAuthLoginNoEnc is the "LOGIN" SASL authentication mechanism. This authentication mechanism
// does not have an official RFC that could be followed. There is a spec by Microsoft and an
// IETF draft. The IETF draft is more lax than the MS spec, therefore we follow the I-D, which
// automatically matches the MS spec.
//
// Since the "LOGIN" SASL authentication mechanism transmits the username and password in
// plaintext over the internet connection, by default we only allow this mechanism over
// a TLS secured connection. This authentiation mechanism overrides this default and will
// allow LOGIN authentication via an unencrypted channel. This can be useful if the
// connection has already been secured in a different way (e. g. a SSH tunnel)
//
// Note: Use this authentication method with caution. If used in the wrong way, you might
// expose your authentication information over unencrypted channels!
//
// https://msopenspecs.azureedge.net/files/MS-XLOGIN/%5bMS-XLOGIN%5d.pdf
//
// https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
SMTPAuthLoginNoEnc SMTPAuthType = "LOGIN-NOENC"
// SMTPAuthNoAuth is equivalent to performing no authentication at all. It is a convenience // SMTPAuthNoAuth is equivalent to performing no authentication at all. It is a convenience
// option and should not be used. Instead, for mail servers that do no support/require // option and should not be used. Instead, for mail servers that do no support/require
// authentication, the Client should not be passed the WithSMTPAuth option at all. // authentication, the Client should not be passed the WithSMTPAuth option at all.
SMTPAuthNoAuth SMTPAuthType = "" SMTPAuthNoAuth SMTPAuthType = "NOAUTH"
// SMTPAuthPlain is the "PLAIN" authentication mechanism as described in RFC 4616. // SMTPAuthPlain is the "PLAIN" authentication mechanism as described in RFC 4616.
// //
// Since the "PLAIN" SASL authentication mechansim transmits the username and password in // Since the "PLAIN" SASL authentication mechanism transmits the username and password in
// plaintext over the internet connection, we only allow this mechanism over a TLS secured // plaintext over the internet connection, we only allow this mechanism over a TLS secured
// connection. // connection.
// //
// https://datatracker.ietf.org/doc/html/rfc4616/ // https://datatracker.ietf.org/doc/html/rfc4616/
SMTPAuthPlain SMTPAuthType = "PLAIN" SMTPAuthPlain SMTPAuthType = "PLAIN"
// SMTPAuthPlainNoEnc is the "PLAIN" authentication mechanism as described in RFC 4616.
//
// Since the "PLAIN" SASL authentication mechanism transmits the username and password in
// plaintext over the internet connection, by default we only allow this mechanism over
// a TLS secured connection. This authentiation mechanism overrides this default and will
// allow PLAIN authentication via an unencrypted channel. This can be useful if the
// connection has already been secured in a different way (e. g. a SSH tunnel)
//
// Note: Use this authentication method with caution. If used in the wrong way, you might
// expose your authentication information over unencrypted channels!
//
// https://datatracker.ietf.org/doc/html/rfc4616/
SMTPAuthPlainNoEnc SMTPAuthType = "PLAIN-NOENC"
// SMTPAuthXOAUTH2 is the "XOAUTH2" SASL authentication mechanism. // SMTPAuthXOAUTH2 is the "XOAUTH2" SASL authentication mechanism.
// https://developers.google.com/gmail/imap/xoauth2-protocol // https://developers.google.com/gmail/imap/xoauth2-protocol
SMTPAuthXOAUTH2 SMTPAuthType = "XOAUTH2" SMTPAuthXOAUTH2 SMTPAuthType = "XOAUTH2"
@ -76,7 +113,7 @@ const (
// //
// SCRAM-SHA-X-PLUS authentication require TLS channel bindings to protect against MitM attacks and // SCRAM-SHA-X-PLUS authentication require TLS channel bindings to protect against MitM attacks and
// to guarantee that the integrity of the transport layer is preserved throughout the authentication // to guarantee that the integrity of the transport layer is preserved throughout the authentication
// process. Therefore we only allow this mechansim over a TLS secured connection. // process. Therefore we only allow this mechanism over a TLS secured connection.
// //
// SCRAM-SHA-1-PLUS is still considered secure for certain applications, particularly when used as part // SCRAM-SHA-1-PLUS is still considered secure for certain applications, particularly when used as part
// of a challenge-response authentication mechanism (as we use it). However, it is generally // of a challenge-response authentication mechanism (as we use it). However, it is generally
@ -95,7 +132,7 @@ const (
// //
// SCRAM-SHA-X-PLUS authentication require TLS channel bindings to protect against MitM attacks and // SCRAM-SHA-X-PLUS authentication require TLS channel bindings to protect against MitM attacks and
// to guarantee that the integrity of the transport layer is preserved throughout the authentication // to guarantee that the integrity of the transport layer is preserved throughout the authentication
// process. Therefore we only allow this mechansim over a TLS secured connection. // process. Therefore we only allow this mechanism over a TLS secured connection.
// //
// https://datatracker.ietf.org/doc/html/rfc7677 // https://datatracker.ietf.org/doc/html/rfc7677
SMTPAuthSCRAMSHA256PLUS SMTPAuthType = "SCRAM-SHA-256-PLUS" SMTPAuthSCRAMSHA256PLUS SMTPAuthType = "SCRAM-SHA-256-PLUS"
@ -134,3 +171,37 @@ var (
// authentication type. // authentication type.
ErrSCRAMSHA256PLUSAuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-256-PLUS") ErrSCRAMSHA256PLUSAuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-256-PLUS")
) )
// UnmarshalString satisfies the fig.StringUnmarshaler interface for the SMTPAuthType type
// https://pkg.go.dev/github.com/kkyr/fig#StringUnmarshaler
func (sa *SMTPAuthType) UnmarshalString(value string) error {
switch strings.ToLower(value) {
case "cram-md5", "crammd5", "cram":
*sa = SMTPAuthCramMD5
case "custom":
*sa = SMTPAuthCustom
case "login":
*sa = SMTPAuthLogin
case "login-noenc":
*sa = SMTPAuthLoginNoEnc
case "none", "noauth", "no":
*sa = SMTPAuthNoAuth
case "plain":
*sa = SMTPAuthPlain
case "plain-noenc":
*sa = SMTPAuthPlainNoEnc
case "scram-sha-1", "scram-sha1", "scramsha1":
*sa = SMTPAuthSCRAMSHA1
case "scram-sha-1-plus", "scram-sha1-plus", "scramsha1plus":
*sa = SMTPAuthSCRAMSHA1PLUS
case "scram-sha-256", "scram-sha256", "scramsha256":
*sa = SMTPAuthSCRAMSHA256
case "scram-sha-256-plus", "scram-sha256-plus", "scramsha256plus":
*sa = SMTPAuthSCRAMSHA256PLUS
case "xoauth2", "oauth2":
*sa = SMTPAuthXOAUTH2
default:
return fmt.Errorf("unsupported SMTP auth type: %s", value)
}
return nil
}

59
auth_test.go Normal file
View file

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT
package mail
import "testing"
func TestSMTPAuthType_UnmarshalString(t *testing.T) {
tests := []struct {
name string
authString string
expected SMTPAuthType
}{
{"CRAM-MD5: cram-md5", "cram-md5", SMTPAuthCramMD5},
{"CRAM-MD5: crammd5", "crammd5", SMTPAuthCramMD5},
{"CRAM-MD5: cram", "cram", SMTPAuthCramMD5},
{"CUSTOM", "custom", SMTPAuthCustom},
{"LOGIN", "login", SMTPAuthLogin},
{"LOGIN-NOENC", "login-noenc", SMTPAuthLoginNoEnc},
{"NONE: none", "none", SMTPAuthNoAuth},
{"NONE: noauth", "noauth", SMTPAuthNoAuth},
{"NONE: no", "no", SMTPAuthNoAuth},
{"PLAIN", "plain", SMTPAuthPlain},
{"PLAIN-NOENC", "plain-noenc", SMTPAuthPlainNoEnc},
{"SCRAM-SHA-1: scram-sha-1", "scram-sha-1", SMTPAuthSCRAMSHA1},
{"SCRAM-SHA-1: scram-sha1", "scram-sha1", SMTPAuthSCRAMSHA1},
{"SCRAM-SHA-1: scramsha1", "scramsha1", SMTPAuthSCRAMSHA1},
{"SCRAM-SHA-1-PLUS: scram-sha-1-plus", "scram-sha-1-plus", SMTPAuthSCRAMSHA1PLUS},
{"SCRAM-SHA-1-PLUS: scram-sha1-plus", "scram-sha1-plus", SMTPAuthSCRAMSHA1PLUS},
{"SCRAM-SHA-1-PLUS: scramsha1plus", "scramsha1plus", SMTPAuthSCRAMSHA1PLUS},
{"SCRAM-SHA-256: scram-sha-256", "scram-sha-256", SMTPAuthSCRAMSHA256},
{"SCRAM-SHA-256: scram-sha256", "scram-sha256", SMTPAuthSCRAMSHA256},
{"SCRAM-SHA-256: scramsha256", "scramsha256", SMTPAuthSCRAMSHA256},
{"SCRAM-SHA-256-PLUS: scram-sha-256-plus", "scram-sha-256-plus", SMTPAuthSCRAMSHA256PLUS},
{"SCRAM-SHA-256-PLUS: scram-sha256-plus", "scram-sha256-plus", SMTPAuthSCRAMSHA256PLUS},
{"SCRAM-SHA-256-PLUS: scramsha256plus", "scramsha256plus", SMTPAuthSCRAMSHA256PLUS},
{"XOAUTH2: xoauth2", "xoauth2", SMTPAuthXOAUTH2},
{"XOAUTH2: oauth2", "oauth2", SMTPAuthXOAUTH2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var authType SMTPAuthType
if err := authType.UnmarshalString(tt.authString); err != nil {
t.Errorf("UnmarshalString() for type %s failed: %s", tt.authString, err)
}
if authType != tt.expected {
t.Errorf("UnmarshalString() for type %s failed: expected %s, got %s",
tt.authString, tt.expected, authType)
}
})
}
t.Run("should fail", func(t *testing.T) {
var authType SMTPAuthType
if err := authType.UnmarshalString("invalid"); err == nil {
t.Error("UnmarshalString() should have failed")
}
})
}

View file

@ -13,8 +13,8 @@ import (
// in encoding processes. // in encoding processes.
var newlineBytes = []byte(SingleNewLine) var newlineBytes = []byte(SingleNewLine)
// ErrNoOutWriter is the error message returned when no io.Writer is set for Base64LineBreaker. // ErrNoOutWriter is the error returned when no io.Writer is set for Base64LineBreaker.
const ErrNoOutWriter = "no io.Writer 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 // Base64LineBreaker handles base64 encoding with the insertion of new lines after a certain number
// of characters. // of characters.
@ -44,7 +44,7 @@ type Base64LineBreaker struct {
// - err: An error if one occurred during the write operation. // - err: An error if one occurred during the write operation.
func (l *Base64LineBreaker) Write(data []byte) (numBytes int, err error) { func (l *Base64LineBreaker) Write(data []byte) (numBytes int, err error) {
if l.out == nil { if l.out == nil {
err = errors.New(ErrNoOutWriter) err = ErrNoOutWriter
return return
} }
if l.used+len(data) < MaxBodyLength { if l.used+len(data) < MaxBodyLength {

View file

@ -5,487 +5,165 @@
package mail package mail
import ( import (
"bufio"
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"testing" "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 ( var (
errClosedWriter = errors.New("writer is already closed")
errMockDefault = errors.New("mock write error") errMockDefault = errors.New("mock write error")
errMockNewline = errors.New("mock newline error") errMockNewline = errors.New("mock newline error")
) )
// TestBase64LineBreaker tests the Write and Close methods of the Base64LineBreaker
func TestBase64LineBreaker(t *testing.T) { func TestBase64LineBreaker(t *testing.T) {
l, err := os.Open("assets/gopher2.svg") t.Run("write, copy and close", func(t *testing.T) {
if err != nil { logoWriter := bytes.NewBuffer(nil)
t.Errorf("failed to open gopher logo asset: %s", err) lineBreaker := &Base64LineBreaker{out: logoWriter}
return t.Cleanup(func() {
} if err := lineBreaker.Close(); err != nil {
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) t.Errorf("failed to close line breaker: %s", err)
} }
ob := removeNewLines([]byte(logoB64)) })
nb := removeNewLines(wbuf.Bytes()) if _, err := lineBreaker.Write([]byte("testdata")); err != nil {
if string(ob) != string(nb) { t.Errorf("failed to write to line breaker: %s", err)
t.Errorf("generated line breaker output differs from original data")
} }
})
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)
} }
// TestBase64LineBreakerFailures tests the cases in which the Base64LineBreaker would fail logoStat, err := os.Stat("testdata/logo.svg")
func TestBase64LineBreakerFailures(t *testing.T) { if err != nil {
stt := []byte("short") t.Fatalf("failed to stat test data file: %s", err)
ltt := []byte(logoB64) }
if logoStat.Size() != copiedBytes {
t.Errorf("copied %d bytes, but expected %d bytes", copiedBytes, logoStat.Size())
}
// No output writer defined expectedRaw, err := os.ReadFile("testdata/logo.svg.base64")
lb := Base64LineBreaker{} if err != nil {
if _, err := lb.Write(stt); 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") t.Errorf("writing to Base64LineBreaker with no output io.Writer was supposed to failed, but didn't")
} }
if err := lb.Close(); err != nil { 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.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)
}
})
// Closed output writer writeBuffer := &errorWriter{}
wbuf := errorWriter{} lineBreaker := &Base64LineBreaker{out: writeBuffer}
fb := Base64LineBreaker{out: wbuf} _, err = io.Copy(lineBreaker, logo)
if _, err := fb.Write(ltt); err == nil { if err == nil {
t.Errorf("writing to Base64LineBreaker with errorWriter was supposed to failed, but didn't") t.Errorf("writing to Base64LineBreaker with an already closed output io.Writer was " +
"supposed to failed, but didn't")
} }
if err := fb.Close(); err != nil { if !errors.Is(err, errClosedWriter) {
t.Errorf("failed to close Base64LineBreaker: %s", err) t.Errorf("unexpected error while writing to Base64LineBreaker: %s", err)
} }
} })
t.Run("fail on different scenarios with mock writer", func(t *testing.T) {
func TestBase64LineBreaker_WriteAndClose(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
data []byte data []byte
writer io.Writer writer io.Writer
}{ }{
{ {
name: "Write data within MaxBodyLength", name: "write data within MaxBodyLength",
data: []byte("testdata"), data: []byte("testdata"),
writer: &mockWriterExcess{writeError: errMockDefault}, writer: &mockWriterExcess{writeError: errMockDefault},
}, },
{ {
name: "Write data exceeds MaxBodyLength", name: "write data exceeds MaxBodyLength",
data: []byte("verylongtestdataverylongtestdataverylongtestdata" + data: []byte("verylongtestdataverylongtestdataverylongtestdata" +
"verylongtestdataverylongtestdataverylongtestdata"), "verylongtestdataverylongtestdataverylongtestdata"),
writer: &mockWriterExcess{writeError: errMockDefault}, writer: &mockWriterExcess{writeError: errMockDefault},
}, },
{ {
name: "Write data exceeds MaxBodyLength with newline", name: "write data exceeds MaxBodyLength with newline",
data: []byte("verylongtestdataverylongtestdataverylongtestdata" + data: []byte("verylongtestdataverylongtestdataverylongtestdata" +
"verylongtestdataverylongtestdataverylongtestdata"), "verylongtestdataverylongtestdataverylongtestdata"),
writer: &mockWriterNewline{writeError: errMockDefault}, writer: &mockWriterNewline{writeError: errMockDefault},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
blr := &Base64LineBreaker{out: tt.writer} lineBreaker := &Base64LineBreaker{out: tt.writer}
_, err := blr.Write(tt.data) _, err := lineBreaker.Write(tt.data)
if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) { if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) {
t.Errorf("Unexpected error while writing: %v", err) t.Errorf("unexpected error while writing to mock writer: %s", err)
} }
err = blr.Close() err = lineBreaker.Close()
if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) { if err != nil && !errors.Is(err, errMockDefault) && !errors.Is(err, errMockNewline) {
t.Errorf("Unexpected error while closing: %v", err) t.Errorf("unexpected error while closing mock writer: %s", err)
} }
}) })
} }
})
} }
// removeNewLines removes any newline characters from the given data // removeNewLines is a test helper thatremoves all newline characters ('\r' and '\n') from the given byte slice.
func removeNewLines(data []byte) []byte { func removeNewLines(t *testing.T, data []byte) []byte {
t.Helper()
result := make([]byte, len(data)) result := make([]byte, len(data))
n := 0 n := 0
@ -503,11 +181,11 @@ func removeNewLines(data []byte) []byte {
type errorWriter struct{} type errorWriter struct{}
func (e errorWriter) Write([]byte) (int, error) { func (e errorWriter) Write([]byte) (int, error) {
return 0, fmt.Errorf("supposed to always fail") return 0, errClosedWriter
} }
func (e errorWriter) Close() error { func (e errorWriter) Close() error {
return fmt.Errorf("supposed to always fail") return errClosedWriter
} }
type mockWriterExcess struct { type mockWriterExcess struct {
@ -539,19 +217,49 @@ func (w *mockWriterNewline) Write(p []byte) (n int, err error) {
} }
} }
func FuzzBase64LineBreaker_Write(f *testing.F) { func FuzzBase64LineBreaker(f *testing.F) {
f.Add([]byte("abc")) seedData := [][]byte{
f.Add([]byte("def")) []byte(""),
f.Add([]uint8{0o0, 0o1, 0o2, 30, 255}) []byte("abc"),
buf := bytes.Buffer{} []byte("def"),
bw := bufio.NewWriter(&buf) []byte("Hello, World!"),
f.Fuzz(func(t *testing.T, data []byte) { []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!§$%&/()=?`{[]}\\|^~*+#-._'"),
b := &Base64LineBreaker{out: bw} []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"),
if _, err := b.Write(data); err != nil { bytes.Repeat([]byte("A"), MaxBodyLength-1), // Near the line length limit
t.Errorf("failed to write to B64LineBreaker: %s", err) 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},
} }
if err := b.Close(); err != nil { for _, data := range seedData {
t.Errorf("failed to close B64LineBreaker: %s", err) f.Add(data)
}
f.Fuzz(func(t *testing.T, data []byte) {
buffer := bytes.NewBuffer(nil)
lineBreaker := &Base64LineBreaker{
out: buffer,
}
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")
} }
}) })
} }

103
client.go
View file

@ -145,6 +145,9 @@ type (
// isEncrypted indicates wether the Client connection is encrypted or not. // isEncrypted indicates wether the Client connection is encrypted or not.
isEncrypted bool isEncrypted bool
// logAuthData indicates whether authentication-related data should be logged.
logAuthData bool
// logger is a logger that satisfies the log.Logger interface. // logger is a logger that satisfies the log.Logger interface.
logger log.Logger logger log.Logger
@ -239,6 +242,12 @@ var (
// provided as argument to the WithDSN Option. // provided as argument to the WithDSN Option.
ErrInvalidDSNRcptNotifyCombination = errors.New("DSN rcpt notify option NEVER cannot be " + ErrInvalidDSNRcptNotifyCombination = errors.New("DSN rcpt notify option NEVER cannot be " +
"combined with any of SUCCESS, FAILURE or DELAY") "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. // NewClient creates a new Client instance with the provided host and optional configuration Option functions.
@ -256,6 +265,7 @@ var (
// - An error if any critical default values are missing or options fail to apply. // - An error if any critical default values are missing or options fail to apply.
func NewClient(host string, opts ...Option) (*Client, error) { func NewClient(host string, opts ...Option) (*Client, error) {
c := &Client{ c := &Client{
smtpAuthType: SMTPAuthNoAuth,
connTimeout: DefaultTimeout, connTimeout: DefaultTimeout,
host: host, host: host,
port: DefaultPort, port: DefaultPort,
@ -364,9 +374,10 @@ func WithSSLPort(fallback bool) Option {
// WithDebugLog enables debug logging for the Client. // WithDebugLog enables debug logging for the Client.
// //
// This function activates debug logging, which logs incoming and outgoing communication between the // This function activates debug logging, which logs incoming and outgoing communication between the
// Client and the SMTP server to os.Stderr. Be cautious when using this option, as the logs may include // Client and the SMTP server to os.Stderr. By default the debug logging will redact any kind of SMTP
// unencrypted authentication data, depending on the SMTP authentication method in use, which could // authentication data. If you need access to the actual authentication data in your logs, you can
// pose a data protection risk. // enable authentication data logging with the WithLogAuthData option or by setting it with the
// Client.SetLogAuthData method.
// //
// Returns: // Returns:
// - An Option function that enables debug logging for the Client. // - An Option function that enables debug logging for the Client.
@ -505,6 +516,9 @@ func WithSMTPAuth(authtype SMTPAuthType) Option {
// - An Option function that sets the custom SMTP authentication for the Client. // - An Option function that sets the custom SMTP authentication for the Client.
func WithSMTPAuthCustom(smtpAuth smtp.Auth) Option { func WithSMTPAuthCustom(smtpAuth smtp.Auth) Option {
return func(c *Client) error { return func(c *Client) error {
if smtpAuth == nil {
return ErrSMTPAuthMethodIsNil
}
c.smtpAuth = smtpAuth c.smtpAuth = smtpAuth
c.smtpAuthType = SMTPAuthCustom c.smtpAuthType = SMTPAuthCustom
return nil return nil
@ -666,11 +680,30 @@ func WithoutNoop() Option {
// - An Option function that sets the custom DialContextFunc for the Client. // - An Option function that sets the custom DialContextFunc for the Client.
func WithDialContextFunc(dialCtxFunc DialContextFunc) Option { func WithDialContextFunc(dialCtxFunc DialContextFunc) Option {
return func(c *Client) error { return func(c *Client) error {
if dialCtxFunc == nil {
return ErrDialContextFuncIsNil
}
c.dialContextFunc = dialCtxFunc c.dialContextFunc = dialCtxFunc
return nil return nil
} }
} }
// WithLogAuthData enables logging of authentication data.
//
// This function sets the logAuthData field of the Client to true, enabling the logging of authentication data.
//
// Be cautious when using this option, as the logs may include unencrypted authentication data, depending on
// the SMTP authentication method in use, which could pose a data protection risk.
//
// Returns:
// - An Option function that configures the Client to enable authentication data logging.
func WithLogAuthData() Option {
return func(c *Client) error {
c.logAuthData = true
return nil
}
}
// TLSPolicy returns the TLSPolicy that is currently set on the Client as a string. // TLSPolicy returns the TLSPolicy that is currently set on the Client as a string.
// //
// This method retrieves the current TLSPolicy configured for the Client and returns it as a string representation. // This method retrieves the current TLSPolicy configured for the Client and returns it as a string representation.
@ -718,6 +751,7 @@ func (c *Client) SetTLSPolicy(policy TLSPolicy) {
func (c *Client) SetTLSPortPolicy(policy TLSPolicy) { func (c *Client) SetTLSPortPolicy(policy TLSPolicy) {
if c.port == DefaultPort { if c.port == DefaultPort {
c.port = DefaultPortTLS c.port = DefaultPortTLS
c.fallbackPort = 0
if policy == TLSOpportunistic { if policy == TLSOpportunistic {
c.fallbackPort = DefaultPort c.fallbackPort = DefaultPort
@ -865,6 +899,19 @@ func (c *Client) SetSMTPAuthCustom(smtpAuth smtp.Auth) {
c.smtpAuthType = SMTPAuthCustom c.smtpAuthType = SMTPAuthCustom
} }
// SetLogAuthData sets or overrides the logging of SMTP authentication data for the Client.
//
// This function sets the logAuthData field of the Client to true, enabling the logging of authentication data.
//
// Be cautious when using this option, as the logs may include unencrypted authentication data, depending on
// the SMTP authentication method in use, which could pose a data protection risk.
//
// Parameters:
// - logAuth: Set wether or not to log SMTP authentication data for the Client.
func (c *Client) SetLogAuthData(logAuth bool) {
c.logAuthData = logAuth
}
// DialWithContext establishes a connection to the server using the provided context.Context. // DialWithContext establishes a connection to the server using the provided context.Context.
// //
// This function adds a deadline based on the Client's timeout to the provided context.Context // This function adds a deadline based on the Client's timeout to the provided context.Context
@ -921,6 +968,9 @@ func (c *Client) DialWithContext(dialCtx context.Context) error {
if c.useDebugLog { if c.useDebugLog {
c.smtpClient.SetDebugLog(true) c.smtpClient.SetDebugLog(true)
} }
if c.logAuthData {
c.smtpClient.SetLogAuthData()
}
if err = c.smtpClient.Hello(c.helo); err != nil { if err = c.smtpClient.Hello(c.helo); err != nil {
return err return err
} }
@ -1028,19 +1078,23 @@ func (c *Client) DialAndSendWithContext(ctx context.Context, messages ...*Msg) e
// determines the supported authentication methods, and applies the appropriate authentication // determines the supported authentication methods, and applies the appropriate authentication
// type. An error is returned if authentication fails. // type. An error is returned if authentication fails.
// //
// This method first verifies the connection to the SMTP server. If no custom authentication // By default NewClient sets the SMTP authentication type to SMTPAuthNoAuth, meaning, that no
// mechanism is provided, it checks which authentication methods are supported by the server. // SMTP authentication will be performed. If the user makes use of SetSMTPAuth or initialzes the
// Based on the configured SMTPAuthType, it sets up the appropriate authentication mechanism. // client with WithSMTPAuth, the SMTP authentication type will be set in the Client, forcing
// this method to determine if the server supports the selected authentication method and
// assigning the corresponding smtp.Auth function to it.
//
// If the user set a custom SMTP authentication function using SetSMTPAuthCustom or
// WithSMTPAuthCustom, we will not perform any detection and assignment logic and will trust
// the user with their provided smtp.Auth function.
//
// Finally, it attempts to authenticate the client using the selected method. // Finally, it attempts to authenticate the client using the selected method.
// //
// Returns: // Returns:
// - An error if the connection check fails, if no supported authentication method is found, // - An error if the connection check fails, if no supported authentication method is found,
// or if the authentication process fails. // or if the authentication process fails.
func (c *Client) auth() error { func (c *Client) auth() error {
if err := c.checkConn(); err != nil { if c.smtpAuth == nil && c.smtpAuthType != SMTPAuthNoAuth {
return fmt.Errorf("failed to authenticate: %w", err)
}
if c.smtpAuth == nil && c.smtpAuthType != SMTPAuthCustom {
hasSMTPAuth, smtpAuthType := c.smtpClient.Extension("AUTH") hasSMTPAuth, smtpAuthType := c.smtpClient.Extension("AUTH")
if !hasSMTPAuth { if !hasSMTPAuth {
return fmt.Errorf("server does not support SMTP AUTH") return fmt.Errorf("server does not support SMTP AUTH")
@ -1051,12 +1105,22 @@ func (c *Client) auth() error {
if !strings.Contains(smtpAuthType, string(SMTPAuthPlain)) { if !strings.Contains(smtpAuthType, string(SMTPAuthPlain)) {
return ErrPlainAuthNotSupported return ErrPlainAuthNotSupported
} }
c.smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host) c.smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host, false)
case SMTPAuthPlainNoEnc:
if !strings.Contains(smtpAuthType, string(SMTPAuthPlain)) {
return ErrPlainAuthNotSupported
}
c.smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host, true)
case SMTPAuthLogin: case SMTPAuthLogin:
if !strings.Contains(smtpAuthType, string(SMTPAuthLogin)) { if !strings.Contains(smtpAuthType, string(SMTPAuthLogin)) {
return ErrLoginAuthNotSupported return ErrLoginAuthNotSupported
} }
c.smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host) c.smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host, false)
case SMTPAuthLoginNoEnc:
if !strings.Contains(smtpAuthType, string(SMTPAuthLogin)) {
return ErrLoginAuthNotSupported
}
c.smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host, true)
case SMTPAuthCramMD5: case SMTPAuthCramMD5:
if !strings.Contains(smtpAuthType, string(SMTPAuthCramMD5)) { if !strings.Contains(smtpAuthType, string(SMTPAuthCramMD5)) {
return ErrCramMD5AuthNotSupported return ErrCramMD5AuthNotSupported
@ -1197,14 +1261,13 @@ func (c *Client) sendSingleMsg(message *Msg) error {
affectedMsg: message, affectedMsg: message,
} }
} }
message.isDelivered = true
if err = writer.Close(); err != nil { if err = writer.Close(); err != nil {
return &SendError{ return &SendError{
Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err), Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message, affectedMsg: message,
} }
} }
message.isDelivered = true
if err = c.Reset(); err != nil { if err = c.Reset(); err != nil {
return &SendError{ return &SendError{
@ -1212,12 +1275,6 @@ func (c *Client) sendSingleMsg(message *Msg) error {
affectedMsg: message, affectedMsg: message,
} }
} }
if err = c.checkConn(); err != nil {
return &SendError{
Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
}
}
return nil return nil
} }
@ -1234,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 // - An error if there is no active connection, if the NOOP command fails, or if extending
// the deadline fails; otherwise, returns nil. // the deadline fails; otherwise, returns nil.
func (c *Client) checkConn() error { func (c *Client) checkConn() error {
if c.smtpClient == nil {
return ErrNoActiveConnection
}
if !c.smtpClient.HasConnection() { if !c.smtpClient.HasConnection() {
return ErrNoActiveConnection return ErrNoActiveConnection
} }
@ -1292,9 +1352,6 @@ func (c *Client) setDefaultHelo() error {
// - An error if there is no active connection, if STARTTLS is required but not supported, // - 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. // or if there are issues during the TLS handshake; otherwise, returns nil.
func (c *Client) tls() error { func (c *Client) tls() error {
if !c.smtpClient.HasConnection() {
return ErrNoActiveConnection
}
if !c.useSSL && c.tlspolicy != NoTLS { if !c.useSSL && c.tlspolicy != NoTLS {
hasStartTLS := false hasStartTLS := false
extension, _ := c.smtpClient.Extension("STARTTLS") extension, _ := c.smtpClient.Extension("STARTTLS")

126
client_121_test.go Normal file
View file

@ -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")
}
})
}

File diff suppressed because it is too large Load diff

View file

@ -6,17 +6,17 @@ coverage:
status: status:
project: project:
default: default:
target: 85% target: 90%
threshold: 5% threshold: 2%
base: auto base: auto
if_ci_failed: error if_ci_failed: error
only_pulls: false only_pulls: false
patch: patch:
default: default:
target: 80% target: 90%
base: auto base: auto
if_ci_failed: error if_ci_failed: error
threshold: 5% threshold: 2%
comment: comment:
require_changes: true require_changes: true

2
doc.go
View file

@ -11,4 +11,4 @@ package mail
// VERSION indicates the current version of the package. It is also attached to the default user // VERSION indicates the current version of the package. It is also attached to the default user
// agent string. // agent string.
const VERSION = "0.5.0" const VERSION = "0.5.1"

14
eml.go
View file

@ -60,7 +60,7 @@ func EMLToMsgFromReader(reader io.Reader) (*Msg, error) {
return msg, fmt.Errorf("failed to parse EML from reader: %w", err) 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) 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) 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) 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 { for _, addr := range parsedAddrs {
addrStrings = append(addrStrings, addr.String()) addrStrings = append(addrStrings, addr.String())
} }
if err = addrFunc(addrStrings...); err != nil { // We can skip the error checking here since netmail.ParseAddressList already performed the
return fmt.Errorf(`failed to parse %q header: %w`, addrHeader, err) // same address checking that the msg methods do.
} _ = addrFunc(addrStrings...)
} }
} }
@ -383,7 +383,7 @@ ReadNextPart:
return fmt.Errorf("failed to get next part of multipart message: %w", err) return fmt.Errorf("failed to get next part of multipart message: %w", err)
} }
for err == nil { for err == nil {
// Multipart/related and Multipart/alternative parts need to be parsed seperately // Multipart/related and Multipart/alternative parts need to be parsed separately
if contentTypeSlice, ok := multiPart.Header[HeaderContentType.String()]; ok && len(contentTypeSlice) == 1 { if contentTypeSlice, ok := multiPart.Header[HeaderContentType.String()]; ok && len(contentTypeSlice) == 1 {
contentType, _ := parseMultiPartHeader(contentTypeSlice[0]) contentType, _ := parseMultiPartHeader(contentTypeSlice[0])
if strings.EqualFold(contentType, TypeMultipartRelated.String()) || if strings.EqualFold(contentType, TypeMultipartRelated.String()) ||
@ -600,6 +600,8 @@ func parseEMLAttachmentEmbed(contentDisposition []string, multiPart *multipart.P
if err := msg.EmbedReader(filename, dataReader); err != nil { if err := msg.EmbedReader(filename, dataReader); err != nil {
return fmt.Errorf("failed to embed multipart body: %w", err) return fmt.Errorf("failed to embed multipart body: %w", err)
} }
default:
return errors.New("unsupported content disposition type")
} }
return nil return nil
} }

View file

@ -6,11 +6,8 @@ package mail
import ( import (
"bytes" "bytes"
"fmt"
"os"
"strings" "strings"
"testing" "testing"
"time"
) )
const ( const (
@ -22,6 +19,23 @@ Subject: Saying Hello
Date: Fri, 21 Nov 1997 09:55:06 -0600 Date: Fri, 21 Nov 1997 09:55:06 -0600
Message-ID: <1234@local.machine.example> Message-ID: <1234@local.machine.example>
This is a message just to say hello.
So, "Hello".`
exampleMailRFC5322A11InvalidFrom = `From: §§§§§§§§§
To: Mary Smith <mary@example.net>
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 <jdoe@machine.example>
To: Mary Smith <mary@example.net>
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. This is a message just to say hello.
So, "Hello".` So, "Hello".`
exampleMailPlainNoEnc = `Date: Wed, 01 Nov 2023 00:00:00 +0000 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. 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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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! Thank your for your business!
The go-mail team The go-mail team
@ -304,6 +364,128 @@ VGhpcyBpcyBhIHNpbXBsZSB0ZXN0IHRleHQgZmlsZSBhdHRhY2htZW50LgoKSXQgCiAgaGFzCiAg
ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh ICAgc2V2ZXJhbAogICAgICAgICAgICBuZXdsaW5lcwoJICAgICAgICAgICAgYW5kCgkgICAgc3Bh
Y2VzCiAgICAgaW4KICBpdAouCgpBcyB3ZWxsIGFzIGFuIGVtb2ppOiDwn5mCCg== 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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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--` --45c75ff528359022eb03679fbe91877d75343f2e1f8193e349deffa33ff7--`
exampleMailPlainB64WithAttachmentNoBoundary = `Date: Wed, 01 Nov 2023 00:00:00 +0000 exampleMailPlainB64WithAttachmentNoBoundary = `Date: Wed, 01 Nov 2023 00:00:00 +0000
MIME-Version: 1.0 MIME-Version: 1.0
@ -578,6 +760,39 @@ Content-Disposition: attachment;
filename="testfile.txt" filename="testfile.txt"
VGhpcyBpcyBhIHRlc3QgaW4gQmFzZTY0 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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Cc: <go-mail+cc@go-mail.dev>
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--` --------------26A45336F6C6196BD8BBA2A2--`
exampleMultiPart8BitBase64 = `Date: Wed, 01 Nov 2023 00:00:00 +0000 exampleMultiPart8BitBase64 = `Date: Wed, 01 Nov 2023 00:00:00 +0000
MIME-Version: 1.0 MIME-Version: 1.0
@ -612,8 +827,352 @@ Content-Disposition: attachment;
VGhpcyBpcyBhIHRlc3QgaW4gQmFzZTY0 VGhpcyBpcyBhIHRlc3QgaW4gQmFzZTY0
--------------26A45336F6C6196BD8BBA2A2--` --------------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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Content-Type: multipart/related; boundary="abc123"
--abc123
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<html>
<body>
<p>Hello,</p>
<p>This is an example email with an inline image:</p>
<img src="cid:12345@go-mail.dev" alt="Inline Image">
<p>Best regards,<br>The go-mail team</p>
</body>
</html>
--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" <go-mail@go-mail.dev>
To: <go-mail+test@go-mail.dev>
Content-Type: multipart/related; boundary="abc123"
--abc123
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<html>
<body>
<p>Hello,</p>
<p>This is an example email with an inline image:</p>
<img src="cid:12345@go-mail.dev" alt="Inline Image">
<p>Best regards,<br>The go-mail team</p>
</body>
</html>
--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) { func TestEMLToMsgFromString(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -621,26 +1180,6 @@ func TestEMLToMsgFromString(t *testing.T) {
enc string enc string
sub 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -1009,3 +1548,6 @@ func stringToTempFile(data, name string) (string, string, error) {
} }
return tempDir, filePath, nil return tempDir, filePath, nil
} }
*/

View file

@ -6,134 +6,183 @@ package mail
import "testing" import "testing"
// TestFile_SetGetHeader tests the set-/getHeader method of the File object func TestFile(t *testing.T) {
func TestFile_SetGetHeader(t *testing.T) { t.Run("setHeader", func(t *testing.T) {
f := File{ f := File{
Name: "testfile.txt", Name: "testfile.txt",
Header: make(map[string][]string), Header: make(map[string][]string),
} }
f.setHeader(HeaderContentType, "text/plain") f.setHeader(HeaderContentType, "text/plain")
fi, ok := f.getHeader(HeaderContentType) contentType, ok := f.Header[HeaderContentType.String()]
if !ok { if !ok {
t.Errorf("getHeader method of File did not return a value") t.Fatalf("setHeader failed. Expected header %s to be set", HeaderContentType)
return
} }
if fi != "text/plain" { if len(contentType) != 1 {
t.Errorf("getHeader returned wrong value. Expected: %s, got: %s", "text/plain", fi) t.Fatalf("setHeader failed. Expected header %s to have one value, got: %d", HeaderContentType,
len(contentType))
} }
fi, ok = f.getHeader(HeaderContentTransferEnc) if contentType[0] != "text/plain" {
if ok { t.Fatalf("setHeader failed. Expected header %s to have value %s, got: %s",
t.Errorf("getHeader method of File did return a value, but wasn't supposed to") HeaderContentType.String(), "text/plain", contentType[0])
return
} }
if fi != "" { })
t.Errorf("getHeader returned wrong value. Expected: %s, got: %s", "", fi) 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" {
// TestFile_WithFileDescription tests the WithFileDescription option t.Fatalf("setHeader failed. Expected header %s to have value %s, got: %s",
func TestFile_WithFileDescription(t *testing.T) { HeaderContentType.String(), "text/plain", contentType)
}
})
t.Run("WithFileDescription", func(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
desc string desc string
}{ }{
{"File description: test", "test"}, {"File description: test", "test"},
{"File description: with newline", "test\n"},
{"File description: empty", ""}, {"File description: empty", ""},
} }
for _, tt := range tests { for _, tt := range tests {
m := NewMsg()
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
m.AttachFile("file.go", WithFileDescription(tt.desc)) message := NewMsg()
al := m.GetAttachments() message.AttachFile("file.go", WithFileDescription(tt.desc))
if len(al) <= 0 { attachments := message.GetAttachments()
t.Errorf("AttachFile() failed. Attachment list is empty") if len(attachments) <= 0 {
t.Fatalf("failed to retrieve attachments list")
} }
a := al[0] firstAttachment := attachments[0]
if a.Desc != tt.desc { if firstAttachment == nil {
t.Errorf("WithFileDescription() failed. Expected: %s, got: %s", tt.desc, a.Desc) 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) {
// TestFile_WithContentID tests the WithFileContentID option
func TestFile_WithContentID(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
contentid string id string
}{ }{
{"File Content-ID: test", "test"}, {"Content-ID: test", "test"},
{"File Content-ID: empty", ""}, {"Content-ID: with newline", "test\n"},
{"Content-ID: empty", ""},
} }
for _, tt := range tests { for _, tt := range tests {
m := NewMsg()
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
m.AttachFile("file.go", WithFileContentID(tt.contentid)) message := NewMsg()
al := m.GetAttachments() message.AttachFile("file.go", WithFileContentID(tt.id))
if len(al) <= 0 { attachments := message.GetAttachments()
t.Errorf("AttachFile() failed. Attachment list is empty") if len(attachments) <= 0 {
t.Fatalf("failed to retrieve attachments list")
} }
a := al[0] firstAttachment := attachments[0]
if a.Header.Get(HeaderContentID.String()) != tt.contentid { if firstAttachment == nil {
t.Errorf("WithFileContentID() failed. Expected: %s, got: %s", tt.contentid, t.Fatalf("failed to retrieve first attachment, got nil")
a.Header.Get(HeaderContentID.String())) }
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) {
// TestFile_WithFileEncoding tests the WithFileEncoding option
func TestFile_WithFileEncoding(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
enc Encoding encoding Encoding
want Encoding want Encoding
}{ }{
{"File encoding: US-ASCII", EncodingUSASCII, EncodingUSASCII},
{"File encoding: 8bit raw", NoEncoding, NoEncoding}, {"File encoding: 8bit raw", NoEncoding, NoEncoding},
{"File encoding: Base64", EncodingB64, EncodingB64}, {"File encoding: Base64", EncodingB64, EncodingB64},
{"File encoding: quoted-printable (not allowed)", EncodingQP, ""}, {"File encoding: quoted-printable (not allowed)", EncodingQP, ""},
} }
for _, tt := range tests { for _, tt := range tests {
m := NewMsg()
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
m.AttachFile("file.go", WithFileEncoding(tt.enc)) message := NewMsg()
al := m.GetAttachments() message.AttachFile("file.go", WithFileEncoding(tt.encoding))
if len(al) <= 0 { attachments := message.GetAttachments()
t.Errorf("AttachFile() failed. Attachment list is empty") if len(attachments) <= 0 {
t.Fatalf("failed to retrieve attachments list")
} }
a := al[0] firstAttachment := attachments[0]
if a.Enc != tt.want { if firstAttachment == nil {
t.Errorf("WithFileEncoding() failed. Expected: %s, got: %s", tt.enc, a.Enc) 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) {
// TestFile_WithFileContentType tests the WithFileContentType option
func TestFile_WithFileContentType(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
ct ContentType fileName string
want string
}{ }{
{"File content-type: text/plain", TypeTextPlain, "text/plain"}, {"File name: test", "test"},
{"File content-type: html/html", TypeTextHTML, "text/html"}, {"File name: with newline", "test\n"},
{"File content-type: application/octet-stream", TypeAppOctetStream, "application/octet-stream"}, {"File name: empty", ""},
{"File content-type: application/pgp-encrypted", TypePGPEncrypted, "application/pgp-encrypted"},
{"File content-type: application/pgp-signature", TypePGPSignature, "application/pgp-signature"},
} }
for _, tt := range tests { for _, tt := range tests {
m := NewMsg()
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
m.AttachFile("file.go", WithFileContentType(tt.ct)) message := NewMsg()
al := m.GetAttachments() message.AttachFile("file.go", WithFileName(tt.fileName))
if len(al) <= 0 { attachments := message.GetAttachments()
t.Errorf("AttachFile() failed. Attachment list is empty") if len(attachments) <= 0 {
t.Fatalf("failed to retrieve attachments list")
} }
a := al[0] firstAttachment := attachments[0]
if a.ContentType != ContentType(tt.want) { if firstAttachment == nil {
t.Errorf("WithFileContentType() failed. Expected: %s, got: %s", tt.want, a.ContentType) 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)
}
})
}
})
} }

View file

@ -8,69 +8,13 @@ import (
"testing" "testing"
) )
// TestImportance_StringFuncs tests the different string method of the Importance object var (
func TestImportance_StringFuncs(t *testing.T) { genHeaderTests = []struct {
tests := []struct {
name string name string
imp Importance header Header
wantns string
xprio string
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 want string
}{ }{
{"Header: Content-Description", HeaderContentDescription, "Content-Description"},
{"Header: Content-Disposition", HeaderContentDisposition, "Content-Disposition"}, {"Header: Content-Disposition", HeaderContentDisposition, "Content-Disposition"},
{"Header: Content-ID", HeaderContentID, "Content-ID"}, {"Header: Content-ID", HeaderContentID, "Content-ID"},
{"Header: Content-Language", HeaderContentLang, "Content-Language"}, {"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-Transfer-Encoding", HeaderContentTransferEnc, "Content-Transfer-Encoding"},
{"Header: Content-Type", HeaderContentType, "Content-Type"}, {"Header: Content-Type", HeaderContentType, "Content-Type"},
{"Header: Date", HeaderDate, "Date"}, {"Header: Date", HeaderDate, "Date"},
{
"Header: Disposition-Notification-To", HeaderDispositionNotificationTo,
"Disposition-Notification-To",
},
{"Header: Importance", HeaderImportance, "Importance"}, {"Header: Importance", HeaderImportance, "Importance"},
{"Header: In-Reply-To", HeaderInReplyTo, "In-Reply-To"}, {"Header: In-Reply-To", HeaderInReplyTo, "In-Reply-To"},
{"Header: List-Unsubscribe", HeaderListUnsubscribe, "List-Unsubscribe"}, {"Header: List-Unsubscribe", HeaderListUnsubscribe, "List-Unsubscribe"},
@ -87,19 +35,90 @@ func TestHeader_String(t *testing.T) {
{"Header: Organization", HeaderOrganization, "Organization"}, {"Header: Organization", HeaderOrganization, "Organization"},
{"Header: Precedence", HeaderPrecedence, "Precedence"}, {"Header: Precedence", HeaderPrecedence, "Precedence"},
{"Header: Priority", HeaderPriority, "Priority"}, {"Header: Priority", HeaderPriority, "Priority"},
{"Header: HeaderReferences", HeaderReferences, "References"}, {"Header: References", HeaderReferences, "References"},
{"Header: Reply-To", HeaderReplyTo, "Reply-To"}, {"Header: Reply-To", HeaderReplyTo, "Reply-To"},
{"Header: Subject", HeaderSubject, "Subject"}, {"Header: Subject", HeaderSubject, "Subject"},
{"Header: User-Agent", HeaderUserAgent, "User-Agent"}, {"Header: User-Agent", HeaderUserAgent, "User-Agent"},
{"Header: X-Auto-Response-Suppress", HeaderXAutoResponseSuppress, "X-Auto-Response-Suppress"},
{"Header: X-Mailer", HeaderXMailer, "X-Mailer"}, {"Header: X-Mailer", HeaderXMailer, "X-Mailer"},
{"Header: X-MSMail-Priority", HeaderXMSMailPriority, "X-MSMail-Priority"}, {"Header: X-MSMail-Priority", HeaderXMSMailPriority, "X-MSMail-Priority"},
{"Header: X-Priority", HeaderXPriority, "X-Priority"}, {"Header: X-Priority", HeaderXPriority, "X-Priority"},
} }
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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if tt.h.String() != tt.want { if tt.imp.String() != tt.want {
t.Errorf("wrong string for Header returned. Expected: %s, got: %s", t.Errorf("wrong string for Importance returned. Expected: %s, got: %s", tt.want, tt.imp.String())
tt.want, tt.h.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.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())
} }
}) })
} }

View file

@ -41,42 +41,48 @@ func NewJSON(output io.Writer, level Level) *JSONlog {
} }
} }
// logMessage is a helper function to handle different log levels and formats.
func logMessage(level Level, log *slog.Logger, logData Log, formatFunc func(string, ...interface{}) string) {
lGroup := log.WithGroup(DirString).With(
slog.String(DirFromString, logData.directionFrom()),
slog.String(DirToString, logData.directionTo()),
)
switch level {
case LevelDebug:
lGroup.Debug(formatFunc(logData.Format, logData.Messages...))
case LevelInfo:
lGroup.Info(formatFunc(logData.Format, logData.Messages...))
case LevelWarn:
lGroup.Warn(formatFunc(logData.Format, logData.Messages...))
case LevelError:
lGroup.Error(formatFunc(logData.Format, logData.Messages...))
}
}
// Debugf logs a debug message via the structured JSON logger // Debugf logs a debug message via the structured JSON logger
func (l *JSONlog) Debugf(log Log) { func (l *JSONlog) Debugf(log Log) {
if l.level >= LevelDebug { if l.level >= LevelDebug {
l.log.WithGroup(DirString).With( logMessage(LevelDebug, l.log, log, fmt.Sprintf)
slog.String(DirFromString, log.directionFrom()),
slog.String(DirToString, log.directionTo()),
).Debug(fmt.Sprintf(log.Format, log.Messages...))
} }
} }
// Infof logs a info message via the structured JSON logger // Infof logs a info message via the structured JSON logger
func (l *JSONlog) Infof(log Log) { func (l *JSONlog) Infof(log Log) {
if l.level >= LevelInfo { if l.level >= LevelInfo {
l.log.WithGroup(DirString).With( logMessage(LevelInfo, l.log, log, fmt.Sprintf)
slog.String(DirFromString, log.directionFrom()),
slog.String(DirToString, log.directionTo()),
).Info(fmt.Sprintf(log.Format, log.Messages...))
} }
} }
// Warnf logs a warn message via the structured JSON logger // Warnf logs a warn message via the structured JSON logger
func (l *JSONlog) Warnf(log Log) { func (l *JSONlog) Warnf(log Log) {
if l.level >= LevelWarn { if l.level >= LevelWarn {
l.log.WithGroup(DirString).With( logMessage(LevelWarn, l.log, log, fmt.Sprintf)
slog.String(DirFromString, log.directionFrom()),
slog.String(DirToString, log.directionTo()),
).Warn(fmt.Sprintf(log.Format, log.Messages...))
} }
} }
// Errorf logs a warn message via the structured JSON logger // Errorf logs a warn message via the structured JSON logger
func (l *JSONlog) Errorf(log Log) { func (l *JSONlog) Errorf(log Log) {
if l.level >= LevelError { if l.level >= LevelError {
l.log.WithGroup(DirString).With( logMessage(LevelError, l.log, log, fmt.Sprintf)
slog.String(DirFromString, log.directionFrom()),
slog.String(DirToString, log.directionTo()),
).Error(fmt.Sprintf(log.Format, log.Messages...))
} }
} }

View file

@ -35,34 +35,36 @@ func New(output io.Writer, level Level) *Stdlog {
} }
} }
// logStdMessage is a helper function to handle different log levels and formats for Stdlog.
func logStdMessage(logger *log.Logger, logData Log, callDepth int) {
format := fmt.Sprintf("%s %s", logData.directionPrefix(), logData.Format)
_ = logger.Output(callDepth, fmt.Sprintf(format, logData.Messages...))
}
// Debugf performs a Printf() on the debug logger // Debugf performs a Printf() on the debug logger
func (l *Stdlog) Debugf(log Log) { func (l *Stdlog) Debugf(log Log) {
if l.level >= LevelDebug { if l.level >= LevelDebug {
format := fmt.Sprintf("%s %s", log.directionPrefix(), log.Format) logStdMessage(l.debug, log, CallDepth)
_ = l.debug.Output(CallDepth, fmt.Sprintf(format, log.Messages...))
} }
} }
// Infof performs a Printf() on the info logger // Infof performs a Printf() on the info logger
func (l *Stdlog) Infof(log Log) { func (l *Stdlog) Infof(log Log) {
if l.level >= LevelInfo { if l.level >= LevelInfo {
format := fmt.Sprintf("%s %s", log.directionPrefix(), log.Format) logStdMessage(l.info, log, CallDepth)
_ = l.info.Output(CallDepth, fmt.Sprintf(format, log.Messages...))
} }
} }
// Warnf performs a Printf() on the warn logger // Warnf performs a Printf() on the warn logger
func (l *Stdlog) Warnf(log Log) { func (l *Stdlog) Warnf(log Log) {
if l.level >= LevelWarn { if l.level >= LevelWarn {
format := fmt.Sprintf("%s %s", log.directionPrefix(), log.Format) logStdMessage(l.warn, log, CallDepth)
_ = l.warn.Output(CallDepth, fmt.Sprintf(format, log.Messages...))
} }
} }
// Errorf performs a Printf() on the error logger // Errorf performs a Printf() on the error logger
func (l *Stdlog) Errorf(log Log) { func (l *Stdlog) Errorf(log Log) {
if l.level >= LevelError { if l.level >= LevelError {
format := fmt.Sprintf("%s %s", log.directionPrefix(), log.Format) logStdMessage(l.err, log, CallDepth)
_ = l.err.Output(CallDepth, fmt.Sprintf(format, log.Messages...))
} }
} }

87
msg.go
View file

@ -654,6 +654,9 @@ func (m *Msg) SetAddrHeader(header AddrHeader, values ...string) error {
// References: // References:
// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4
func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) { func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) {
if m.addrHeader == nil {
m.addrHeader = make(map[AddrHeader][]*mail.Address)
}
var addresses []*mail.Address var addresses []*mail.Address
for _, addrVal := range values { for _, addrVal := range values {
address, err := mail.ParseAddress(m.encodeString(addrVal)) address, err := mail.ParseAddress(m.encodeString(addrVal))
@ -662,8 +665,15 @@ func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) {
} }
addresses = append(addresses, address) addresses = append(addresses, address)
} }
switch header {
case HeaderFrom:
if len(addresses) > 0 {
m.addrHeader[header] = []*mail.Address{addresses[0]}
}
default:
m.addrHeader[header] = addresses m.addrHeader[header] = addresses
} }
}
// EnvelopeFrom sets the envelope from address for the Msg. // EnvelopeFrom sets the envelope from address for the Msg.
// //
@ -814,7 +824,16 @@ func (m *Msg) ToIgnoreInvalid(rcpts ...string) {
// References: // References:
// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3
func (m *Msg) ToFromString(rcpts string) error { 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. // Cc sets one or more "CC" (carbon copy) addresses in the mail body for the Msg.
@ -899,7 +918,16 @@ func (m *Msg) CcIgnoreInvalid(rcpts ...string) {
// References: // References:
// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3
func (m *Msg) CcFromString(rcpts string) error { 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. // Bcc sets one or more "BCC" (blind carbon copy) addresses in the mail body for the Msg.
@ -985,7 +1013,16 @@ func (m *Msg) BccIgnoreInvalid(rcpts ...string) {
// References: // References:
// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3
func (m *Msg) BccFromString(rcpts string) error { 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. // ReplyTo sets the "Reply-To" address for the Msg, specifying where replies should be sent.
@ -1121,8 +1158,7 @@ func (m *Msg) SetBulk() {
// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.3 // - https://datatracker.ietf.org/doc/html/rfc5322#section-3.3
// - https://datatracker.ietf.org/doc/html/rfc1123 // - https://datatracker.ietf.org/doc/html/rfc1123
func (m *Msg) SetDate() { func (m *Msg) SetDate() {
now := time.Now().Format(time.RFC1123Z) m.SetDateWithValue(time.Now())
m.SetGenHeader(HeaderDate, now)
} }
// SetDateWithValue sets the "Date" header for the Msg using the provided time value in a valid RFC 1123 format. // SetDateWithValue sets the "Date" header for the Msg using the provided time value in a valid RFC 1123 format.
@ -1222,6 +1258,9 @@ func (m *Msg) IsDelivered() bool {
// References: // References:
// - https://datatracker.ietf.org/doc/html/rfc8098 // - https://datatracker.ietf.org/doc/html/rfc8098
func (m *Msg) RequestMDNTo(rcpts ...string) error { func (m *Msg) RequestMDNTo(rcpts ...string) error {
if m.genHeader == nil {
m.genHeader = make(map[Header][]string)
}
var addresses []string var addresses []string
for _, addrVal := range rcpts { for _, addrVal := range rcpts {
address, err := mail.ParseAddress(addrVal) address, err := mail.ParseAddress(addrVal)
@ -1230,9 +1269,7 @@ func (m *Msg) RequestMDNTo(rcpts ...string) error {
} }
addresses = append(addresses, address.String()) addresses = append(addresses, address.String())
} }
if _, ok := m.genHeader[HeaderDispositionNotificationTo]; ok {
m.genHeader[HeaderDispositionNotificationTo] = addresses m.genHeader[HeaderDispositionNotificationTo] = addresses
}
return nil return nil
} }
@ -1271,11 +1308,11 @@ func (m *Msg) RequestMDNAddTo(rcpt string) error {
return fmt.Errorf(errParseMailAddr, rcpt, err) return fmt.Errorf(errParseMailAddr, rcpt, err)
} }
var addresses []string var addresses []string
addresses = append(addresses, m.genHeader[HeaderDispositionNotificationTo]...) if current, ok := m.genHeader[HeaderDispositionNotificationTo]; ok {
addresses = append(addresses, address.String()) addresses = current
if _, ok := m.genHeader[HeaderDispositionNotificationTo]; ok {
m.genHeader[HeaderDispositionNotificationTo] = addresses
} }
addresses = append(addresses, address.String())
m.genHeader[HeaderDispositionNotificationTo] = addresses
return nil return nil
} }
@ -1715,11 +1752,11 @@ func (m *Msg) SetBodyHTMLTemplate(tpl *ht.Template, data interface{}, opts ...Pa
if tpl == nil { if tpl == nil {
return errors.New(errTplPointerNil) return errors.New(errTplPointerNil)
} }
buffer := bytes.Buffer{} buffer := bytes.NewBuffer(nil)
if err := tpl.Execute(&buffer, data); err != nil { if err := tpl.Execute(buffer, data); err != nil {
return fmt.Errorf(errTplExecuteFailed, err) return fmt.Errorf(errTplExecuteFailed, err)
} }
writeFunc := writeFuncFromBuffer(&buffer) writeFunc := writeFuncFromBuffer(buffer)
m.SetBodyWriter(TypeTextHTML, writeFunc, opts...) m.SetBodyWriter(TypeTextHTML, writeFunc, opts...)
return nil return nil
} }
@ -1746,11 +1783,11 @@ func (m *Msg) SetBodyTextTemplate(tpl *tt.Template, data interface{}, opts ...Pa
if tpl == nil { if tpl == nil {
return errors.New(errTplPointerNil) return errors.New(errTplPointerNil)
} }
buf := bytes.Buffer{} buffer := bytes.NewBuffer(nil)
if err := tpl.Execute(&buf, data); err != nil { if err := tpl.Execute(buffer, data); err != nil {
return fmt.Errorf(errTplExecuteFailed, err) return fmt.Errorf(errTplExecuteFailed, err)
} }
writeFunc := writeFuncFromBuffer(&buf) writeFunc := writeFuncFromBuffer(buffer)
m.SetBodyWriter(TypeTextPlain, writeFunc, opts...) m.SetBodyWriter(TypeTextPlain, writeFunc, opts...)
return nil return nil
} }
@ -1820,11 +1857,11 @@ func (m *Msg) AddAlternativeHTMLTemplate(tpl *ht.Template, data interface{}, opt
if tpl == nil { if tpl == nil {
return errors.New(errTplPointerNil) return errors.New(errTplPointerNil)
} }
buffer := bytes.Buffer{} buffer := bytes.NewBuffer(nil)
if err := tpl.Execute(&buffer, data); err != nil { if err := tpl.Execute(buffer, data); err != nil {
return fmt.Errorf(errTplExecuteFailed, err) return fmt.Errorf(errTplExecuteFailed, err)
} }
writeFunc := writeFuncFromBuffer(&buffer) writeFunc := writeFuncFromBuffer(buffer)
m.AddAlternativeWriter(TypeTextHTML, writeFunc, opts...) m.AddAlternativeWriter(TypeTextHTML, writeFunc, opts...)
return nil return nil
} }
@ -1850,11 +1887,11 @@ func (m *Msg) AddAlternativeTextTemplate(tpl *tt.Template, data interface{}, opt
if tpl == nil { if tpl == nil {
return errors.New(errTplPointerNil) return errors.New(errTplPointerNil)
} }
buffer := bytes.Buffer{} buffer := bytes.NewBuffer(nil)
if err := tpl.Execute(&buffer, data); err != nil { if err := tpl.Execute(buffer, data); err != nil {
return fmt.Errorf(errTplExecuteFailed, err) return fmt.Errorf(errTplExecuteFailed, err)
} }
writeFunc := writeFuncFromBuffer(&buffer) writeFunc := writeFuncFromBuffer(buffer)
m.AddAlternativeWriter(TypeTextPlain, writeFunc, opts...) m.AddAlternativeWriter(TypeTextPlain, writeFunc, opts...)
return nil return nil
} }
@ -2451,8 +2488,8 @@ func (m *Msg) WriteToSendmailWithContext(ctx context.Context, sendmailPath strin
// - https://datatracker.ietf.org/doc/html/rfc5322 // - https://datatracker.ietf.org/doc/html/rfc5322
func (m *Msg) NewReader() *Reader { func (m *Msg) NewReader() *Reader {
reader := &Reader{} reader := &Reader{}
buffer := bytes.Buffer{} buffer := bytes.NewBuffer(nil)
_, err := m.Write(&buffer) _, err := m.Write(buffer)
if err != nil { if err != nil {
reader.err = fmt.Errorf("failed to write Msg to Reader buffer: %w", err) reader.err = fmt.Errorf("failed to write Msg to Reader buffer: %w", err)
} }

View file

@ -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 <tester@example.com>")
_ = 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 <tester@example.com>")
_ = m.To("Ellenor Tester <ellinor@example.com>")
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")
}
}

File diff suppressed because it is too large Load diff

146
msg_unix_test.go Normal file
View file

@ -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)
}
})
}

View file

@ -6,153 +6,675 @@ package mail
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"mime" "mime"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestMsgWriter_Write(t *testing.T) {
bw := &brokenWriter{} t.Run("msgWriter writes to memory for all charsets", func(t *testing.T) {
mw := &msgWriter{writer: bw, charset: CharsetUTF8, encoder: mime.QEncoding} for _, tt := range charsetTests {
_, err := mw.Write([]byte("test")) 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)
}
})
}
})
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 { if err == nil {
t.Errorf("msgWriter WriteTo() with brokenWriter should fail, but didn't") 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")
}
})
} }
// 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) { func TestMsgWriter_writeMsg(t *testing.T) {
m := NewMsg() msgwriter := &msgWriter{
_ = m.From(`"Toni Tester" <test@example.com>`) charset: CharsetUTF8,
_ = m.To(`"Toni Receiver" <receiver@example.com>`) encoder: getEncoder(EncodingQP),
m.Subject("This is a subject") }
m.SetBulk() t.Run("msgWriter writes a simple message", func(t *testing.T) {
buffer := bytes.NewBuffer(nil)
now := time.Now() now := time.Now()
m.SetDateWithValue(now) msgwriter.writer = buffer
m.SetMessageIDWithValue("message@id.com") message := testMessage(t)
m.SetBodyString(TypeTextPlain, "This is the body") message.SetDateWithValue(now)
m.AddAlternativeString(TypeTextHTML, "This is the alternative body") message.SetMessageIDWithValue("message@id.com")
buf := bytes.Buffer{} message.SetBulk()
mw := &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding} msgwriter.writeMsg(message)
mw.writeMsg(m) if msgwriter.err != nil {
ms := buf.String() t.Errorf("msgWriter failed to write: %s", msgwriter.err)
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: <message@id.com>`) {
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" <test@example.com>`) {
ea = append(ea, "From")
}
if !strings.Contains(ms, `To: "Toni Receiver" <receiver@example.com>`) {
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() var incorrectFields []string
if len(pl) <= 0 { if !strings.Contains(buffer.String(), "MIME-Version: 1.0\r\n") {
t.Errorf("expected multiple parts but got none") incorrectFields = append(incorrectFields, "MIME-Version")
return
} }
if len(pl) == 2 { if !strings.Contains(buffer.String(), fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z))) {
ap := pl[1] incorrectFields = append(incorrectFields, "Date")
ap.SetCharset(CharsetISO88591)
} }
buf.Reset() if !strings.Contains(buffer.String(), "Message-ID: <message@id.com>\r\n") {
mw.writeMsg(m) incorrectFields = append(incorrectFields, "Message-ID")
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`) { if !strings.Contains(buffer.String(), "Precedence: bulk\r\n") {
ea = append(ea, "alternative body charset") 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, "<html><body><h1>Testmail</h1></body></html>")
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())
}
})
} }
if len(ea) > 0 { func TestMsgWriter_writePreformattedGenHeader(t *testing.T) {
em := "writeMsg() failed. The following errors occurred:\n" t.Run("message with no preformatted headerset", func(t *testing.T) {
for e := range ea { buffer := bytes.NewBuffer(nil)
em += fmt.Sprintf("* incorrect %q field", ea[e]) msgwriter := &msgWriter{
writer: buffer,
charset: CharsetUTF8,
encoder: getEncoder(EncodingQP),
} }
em += fmt.Sprintf("\n\nFull message:\n%s", ms) message := testMessage(t)
t.Error(em) 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())
} }
})
} }
// TestMsgWriter_writeMsg_PGP tests the writeMsg method of the msgWriter with PGP types set func TestMsgWriter_addFiles(t *testing.T) {
func TestMsgWriter_writeMsg_PGP(t *testing.T) { msgwriter := &msgWriter{
m := NewMsg(WithPGPType(PGPEncrypt)) charset: CharsetUTF8,
_ = m.From(`"Toni Tester" <test@example.com>`) encoder: getEncoder(EncodingQP),
_ = m.To(`"Toni Receiver" <receiver@example.com>`) }
m.Subject("This is a subject") t.Run("message with a single file attached", func(t *testing.T) {
m.SetBodyString(TypeTextPlain, "This is the body") buffer := bytes.NewBuffer(nil)
buf := bytes.Buffer{} msgwriter.writer = buffer
mw := &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding} message := testMessage(t)
mw.writeMsg(m) message.AttachFile("testdata/attachment.txt")
ms := buf.String() msgwriter.writeMsg(message)
if !strings.Contains(ms, `encrypted; protocol="application/pgp-encrypted"`) { if msgwriter.err != nil {
t.Errorf("writeMsg failed. Expected PGP encoding header but didn't find it in message output") 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())
}
})
} }
m = NewMsg(WithPGPType(PGPSignature)) func TestMsgWriter_writePart(t *testing.T) {
_ = m.From(`"Toni Tester" <test@example.com>`) msgwriter := &msgWriter{
_ = m.To(`"Toni Receiver" <receiver@example.com>`) charset: CharsetUTF8,
m.Subject("This is a subject") encoder: getEncoder(EncodingQP),
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")
} }
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)
}
})
} }
// TestMsgWriter_writeMsg_SMime tests the writeMsg method of the msgWriter with S/MIME types set // TestMsgWriter_writeMsg_SMime tests the writeMsg method of the msgWriter with S/MIME types set

View file

@ -13,6 +13,7 @@ type loginAuth struct {
username, password string username, password string
host string host string
respStep uint8 respStep uint8
allowUnencryptedAuth bool
} }
// LoginAuth returns an [Auth] that implements the LOGIN authentication // LoginAuth returns an [Auth] that implements the LOGIN authentication
@ -29,14 +30,14 @@ type loginAuth struct {
// See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00 // See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
// Since there is no official standard RFC and we've seen different implementations // Since there is no official standard RFC and we've seen different implementations
// of this mechanism (sending "Username:", "Username", "username", "User name", etc.) // of this mechanism (sending "Username:", "Username", "username", "User name", etc.)
// we follow the IETF-Draft and ignore any server challange to allow compatiblity // we follow the IETF-Draft and ignore any server challenge to allow compatibility
// with most mail servers/providers. // with most mail servers/providers.
// //
// LoginAuth will only send the credentials if the connection is using TLS // LoginAuth will only send the credentials if the connection is using TLS
// or is connected to localhost. Otherwise authentication will fail with an // or is connected to localhost. Otherwise authentication will fail with an
// error, without sending the credentials. // error, without sending the credentials.
func LoginAuth(username, password, host string) Auth { func LoginAuth(username, password, host string, allowUnEnc bool) Auth {
return &loginAuth{username, password, host, 0} return &loginAuth{username, password, host, 0, allowUnEnc}
} }
// Start begins the SMTP authentication process by validating server's TLS status and hostname. // Start begins the SMTP authentication process by validating server's TLS status and hostname.
@ -47,7 +48,7 @@ func (a *loginAuth) Start(server *ServerInfo) (string, []byte, error) {
// In particular, it doesn't matter if the server advertises LOGIN auth. // In particular, it doesn't matter if the server advertises LOGIN auth.
// That might just be the attacker saying // That might just be the attacker saying
// "it's ok, you can trust me with your password." // "it's ok, you can trust me with your password."
if !server.TLS && !isLocalhost(server.Name) { if !a.allowUnencryptedAuth && !server.TLS && !isLocalhost(server.Name) {
return "", nil, ErrUnencrypted return "", nil, ErrUnencrypted
} }
if server.Name != a.host { if server.Name != a.host {

View file

@ -17,6 +17,7 @@ package smtp
type plainAuth struct { type plainAuth struct {
identity, username, password string identity, username, password string
host string host string
allowUnencryptedAuth bool
} }
// PlainAuth returns an [Auth] that implements the PLAIN authentication // PlainAuth returns an [Auth] that implements the PLAIN authentication
@ -27,8 +28,8 @@ type plainAuth struct {
// PlainAuth will only send the credentials if the connection is using TLS // PlainAuth will only send the credentials if the connection is using TLS
// or is connected to localhost. Otherwise authentication will fail with an // or is connected to localhost. Otherwise authentication will fail with an
// error, without sending the credentials. // error, without sending the credentials.
func PlainAuth(identity, username, password, host string) Auth { func PlainAuth(identity, username, password, host string, allowUnEnc bool) Auth {
return &plainAuth{identity, username, password, host} return &plainAuth{identity, username, password, host, allowUnEnc}
} }
func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) {
@ -37,7 +38,7 @@ func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) {
// In particular, it doesn't matter if the server advertises PLAIN auth. // In particular, it doesn't matter if the server advertises PLAIN auth.
// That might just be the attacker saying // That might just be the attacker saying
// "it's ok, you can trust me with your password." // "it's ok, you can trust me with your password."
if !server.TLS && !isLocalhost(server.Name) { if !a.allowUnencryptedAuth && !server.TLS && !isLocalhost(server.Name) {
return "", nil, ErrUnencrypted return "", nil, ErrUnencrypted
} }
if server.Name != a.host { if server.Name != a.host {

View file

@ -67,7 +67,7 @@ var (
func ExamplePlainAuth() { func ExamplePlainAuth() {
// hostname is used by PlainAuth to validate the TLS certificate. // hostname is used by PlainAuth to validate the TLS certificate.
hostname := "mail.example.com" hostname := "mail.example.com"
auth := smtp.PlainAuth("", "user@example.com", "password", hostname) auth := smtp.PlainAuth("", "user@example.com", "password", hostname, false)
err := smtp.SendMail(hostname+":25", auth, from, recipients, msg) err := smtp.SendMail(hostname+":25", auth, from, recipients, msg)
if err != nil { if err != nil {
@ -77,7 +77,7 @@ func ExamplePlainAuth() {
func ExampleSendMail() { func ExampleSendMail() {
// Set up authentication information. // Set up authentication information.
auth := smtp.PlainAuth("", "user@example.com", "password", "mail.example.com") auth := smtp.PlainAuth("", "user@example.com", "password", "mail.example.com", false)
// Connect to the server, authenticate, set the sender and recipient, // Connect to the server, authenticate, set the sender and recipient,
// and send the email all in one step. // and send the email all in one step.

View file

@ -54,6 +54,9 @@ type Client struct {
// auth supported auth mechanisms // auth supported auth mechanisms
auth []string auth []string
// authIsActive indicates that the Client is currently during SMTP authentication
authIsActive bool
// keep a reference to the connection so it can be used to create a TLS connection later // keep a reference to the connection so it can be used to create a TLS connection later
conn net.Conn conn net.Conn
@ -78,6 +81,9 @@ type Client struct {
// isConnected indicates if the Client has an active connection // isConnected indicates if the Client has an active connection
isConnected bool isConnected bool
// logAuthData indicates if the Client should include SMTP authentication data in the logs
logAuthData bool
// localName is the name to use in HELO/EHLO // localName is the name to use in HELO/EHLO
localName string // the name to use in HELO/EHLO localName string // the name to use in HELO/EHLO
@ -174,7 +180,15 @@ func (c *Client) Hello(localName string) error {
func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) {
c.mutex.Lock() c.mutex.Lock()
c.debugLog(log.DirClientToServer, format, args...) var logMsg []interface{}
logMsg = args
logFmt := format
if c.authIsActive {
logMsg = []interface{}{"<SMTP auth data redacted>"}
logFmt = "%s"
}
c.debugLog(log.DirClientToServer, logFmt, logMsg...)
id, err := c.Text.Cmd(format, args...) id, err := c.Text.Cmd(format, args...)
if err != nil { if err != nil {
c.mutex.Unlock() c.mutex.Unlock()
@ -182,7 +196,13 @@ func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, s
} }
c.Text.StartResponse(id) c.Text.StartResponse(id)
code, msg, err := c.Text.ReadResponse(expectCode) code, msg, err := c.Text.ReadResponse(expectCode)
c.debugLog(log.DirServerToClient, "%d %s", code, msg)
logMsg = []interface{}{code, msg}
if c.authIsActive && code >= 300 && code <= 400 {
logMsg = []interface{}{code, "<SMTP auth data redacted>"}
}
c.debugLog(log.DirServerToClient, "%d %s", logMsg...)
c.Text.EndResponse(id) c.Text.EndResponse(id)
c.mutex.Unlock() c.mutex.Unlock()
return code, msg, err return code, msg, err
@ -256,6 +276,20 @@ func (c *Client) Auth(a Auth) error {
if err := c.hello(); err != nil { if err := c.hello(); err != nil {
return err return err
} }
c.mutex.Lock()
if !c.logAuthData {
c.authIsActive = true
}
c.mutex.Unlock()
defer func() {
c.mutex.Lock()
if !c.logAuthData {
c.authIsActive = false
}
c.mutex.Unlock()
}()
encoding := base64.StdEncoding encoding := base64.StdEncoding
mech, resp, err := a.Start(&ServerInfo{c.serverName, c.tls, c.auth}) mech, resp, err := a.Start(&ServerInfo{c.serverName, c.tls, c.auth})
if err != nil { if err != nil {
@ -520,9 +554,9 @@ func (c *Client) Noop() error {
// Quit sends the QUIT command and closes the connection to the server. // Quit sends the QUIT command and closes the connection to the server.
func (c *Client) Quit() error { func (c *Client) Quit() error {
if err := c.hello(); err != nil { // See https://github.com/golang/go/issues/70011
return err _ = c.hello() // ignore error; we're quitting anyhow
}
_, _, err := c.cmd(221, "QUIT") _, _, err := c.cmd(221, "QUIT")
if err != nil { if err != nil {
return err return err
@ -556,6 +590,13 @@ func (c *Client) SetLogger(l log.Logger) {
c.logger = l c.logger = l
} }
// SetLogAuthData enables logging of authentication data in the Client.
func (c *Client) SetLogAuthData() {
c.mutex.Lock()
c.logAuthData = true
c.mutex.Unlock()
}
// SetDSNMailReturnOption sets the DSN mail return option for the Mail method // SetDSNMailReturnOption sets the DSN mail return option for the Mail method
func (c *Client) SetDSNMailReturnOption(d string) { func (c *Client) SetDSNMailReturnOption(d string) {
c.dsnmrtype = d c.dsnmrtype = d

View file

@ -50,7 +50,7 @@ type authTest struct {
var authTests = []authTest{ var authTests = []authTest{
{ {
PlainAuth("", "user", "pass", "testserver"), PlainAuth("", "user", "pass", "testserver", false),
[]string{}, []string{},
"PLAIN", "PLAIN",
[]string{"\x00user\x00pass"}, []string{"\x00user\x00pass"},
@ -58,7 +58,15 @@ var authTests = []authTest{
false, false,
}, },
{ {
PlainAuth("foo", "bar", "baz", "testserver"), PlainAuth("", "user", "pass", "testserver", true),
[]string{},
"PLAIN",
[]string{"\x00user\x00pass"},
[]bool{false, false},
false,
},
{
PlainAuth("foo", "bar", "baz", "testserver", false),
[]string{}, []string{},
"PLAIN", "PLAIN",
[]string{"foo\x00bar\x00baz"}, []string{"foo\x00bar\x00baz"},
@ -66,7 +74,7 @@ var authTests = []authTest{
false, false,
}, },
{ {
PlainAuth("foo", "bar", "baz", "testserver"), PlainAuth("foo", "bar", "baz", "testserver", false),
[]string{"foo"}, []string{"foo"},
"PLAIN", "PLAIN",
[]string{"foo\x00bar\x00baz", ""}, []string{"foo\x00bar\x00baz", ""},
@ -74,7 +82,7 @@ var authTests = []authTest{
false, false,
}, },
{ {
LoginAuth("user", "pass", "testserver"), LoginAuth("user", "pass", "testserver", false),
[]string{"Username:", "Password:"}, []string{"Username:", "Password:"},
"LOGIN", "LOGIN",
[]string{"", "user", "pass"}, []string{"", "user", "pass"},
@ -82,7 +90,15 @@ var authTests = []authTest{
false, false,
}, },
{ {
LoginAuth("user", "pass", "testserver"), LoginAuth("user", "pass", "testserver", true),
[]string{"Username:", "Password:"},
"LOGIN",
[]string{"", "user", "pass"},
[]bool{false, false},
false,
},
{
LoginAuth("user", "pass", "testserver", false),
[]string{"User Name\x00", "Password\x00"}, []string{"User Name\x00", "Password\x00"},
"LOGIN", "LOGIN",
[]string{"", "user", "pass"}, []string{"", "user", "pass"},
@ -90,7 +106,7 @@ var authTests = []authTest{
false, false,
}, },
{ {
LoginAuth("user", "pass", "testserver"), LoginAuth("user", "pass", "testserver", false),
[]string{"Invalid", "Invalid:"}, []string{"Invalid", "Invalid:"},
"LOGIN", "LOGIN",
[]string{"", "user", "pass"}, []string{"", "user", "pass"},
@ -98,7 +114,7 @@ var authTests = []authTest{
false, false,
}, },
{ {
LoginAuth("user", "pass", "testserver"), LoginAuth("user", "pass", "testserver", false),
[]string{"Invalid", "Invalid:", "Too many"}, []string{"Invalid", "Invalid:", "Too many"},
"LOGIN", "LOGIN",
[]string{"", "user", "pass", ""}, []string{"", "user", "pass", ""},
@ -237,7 +253,47 @@ func TestAuthPlain(t *testing.T) {
}, },
} }
for i, tt := range tests { for i, tt := range tests {
auth := PlainAuth("foo", "bar", "baz", tt.authName) auth := PlainAuth("foo", "bar", "baz", tt.authName, false)
_, _, err := auth.Start(tt.server)
got := ""
if err != nil {
got = err.Error()
}
if got != tt.err {
t.Errorf("%d. got error = %q; want %q", i, got, tt.err)
}
}
}
func TestAuthPlainNoEnc(t *testing.T) {
tests := []struct {
authName string
server *ServerInfo
err string
}{
{
authName: "servername",
server: &ServerInfo{Name: "servername", TLS: true},
},
{
// OK to use PlainAuth on localhost without TLS
authName: "localhost",
server: &ServerInfo{Name: "localhost", TLS: false},
},
{
// Also OK on non-TLS secured connections. The NoEnc mechanism is meant to allow
// non-encrypted connections.
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"PLAIN"}},
},
{
authName: "servername",
server: &ServerInfo{Name: "attacker", TLS: true},
err: "wrong host name",
},
}
for i, tt := range tests {
auth := PlainAuth("foo", "bar", "baz", tt.authName, true)
_, _, err := auth.Start(tt.server) _, _, err := auth.Start(tt.server)
got := "" got := ""
if err != nil { if err != nil {
@ -283,7 +339,51 @@ func TestAuthLogin(t *testing.T) {
}, },
} }
for i, tt := range tests { for i, tt := range tests {
auth := LoginAuth("foo", "bar", tt.authName) auth := LoginAuth("foo", "bar", tt.authName, false)
_, _, err := auth.Start(tt.server)
got := ""
if err != nil {
got = err.Error()
}
if got != tt.err {
t.Errorf("%d. got error = %q; want %q", i, got, tt.err)
}
}
}
func TestAuthLoginNoEnc(t *testing.T) {
tests := []struct {
authName string
server *ServerInfo
err string
}{
{
authName: "servername",
server: &ServerInfo{Name: "servername", TLS: true},
},
{
// OK to use LoginAuth on localhost without TLS
authName: "localhost",
server: &ServerInfo{Name: "localhost", TLS: false},
},
{
// Also OK on non-TLS secured connections. The NoEnc mechanism is meant to allow
// non-encrypted connections.
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"LOGIN"}},
},
{
authName: "servername",
server: &ServerInfo{Name: "servername", Auth: []string{"CRAM-MD5"}},
},
{
authName: "servername",
server: &ServerInfo{Name: "attacker", TLS: true},
err: "wrong host name",
},
}
for i, tt := range tests {
auth := LoginAuth("foo", "bar", tt.authName, true)
_, _, err := auth.Start(tt.server) _, _, err := auth.Start(tt.server)
got := "" got := ""
if err != nil { if err != nil {
@ -317,7 +417,11 @@ func TestXOAuth2OK(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient: %v", err) t.Fatalf("NewClient: %v", err)
} }
defer c.Close() defer func() {
if err := c.Close(); err != nil {
t.Errorf("failed to close client: %s", err)
}
}()
auth := XOAuth2Auth("user", "token") auth := XOAuth2Auth("user", "token")
err = c.Auth(auth) err = c.Auth(auth)
@ -355,7 +459,11 @@ func TestXOAuth2Error(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("NewClient: %v", err) t.Fatalf("NewClient: %v", err)
} }
defer c.Close() defer func() {
if err := c.Close(); err != nil {
t.Errorf("failed to close client: %s", err)
}
}()
auth := XOAuth2Auth("user", "token") auth := XOAuth2Auth("user", "token")
err = c.Auth(auth) err = c.Auth(auth)
@ -707,7 +815,7 @@ func TestBasic(t *testing.T) {
// fake TLS so authentication won't complain // fake TLS so authentication won't complain
c.tls = true c.tls = true
c.serverName = "smtp.google.com" c.serverName = "smtp.google.com"
if err := c.Auth(PlainAuth("", "user", "pass", "smtp.google.com")); err != nil { if err := c.Auth(PlainAuth("", "user", "pass", "smtp.google.com", false)); err != nil {
t.Fatalf("AUTH failed: %s", err) t.Fatalf("AUTH failed: %s", err)
} }
@ -792,6 +900,35 @@ Goodbye.
QUIT 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) { func TestExtensions(t *testing.T) {
fake := func(server string) (c *Client, bcmdbuf *bufio.Writer, cmdbuf *strings.Builder) { fake := func(server string) (c *Client, bcmdbuf *bufio.Writer, cmdbuf *strings.Builder) {
server = strings.Join(strings.Split(server, "\n"), "\r\n") server = strings.Join(strings.Split(server, "\n"), "\r\n")
@ -1111,6 +1248,32 @@ func TestClient_SetLogger(t *testing.T) {
c.logger.Debugf(log.Log{Direction: log.DirServerToClient, Format: "%s", Messages: []interface{}{"test"}}) c.logger.Debugf(log.Log{Direction: log.DirServerToClient, Format: "%s", Messages: []interface{}{"test"}})
} }
func TestClient_SetLogAuthData(t *testing.T) {
server := strings.Join(strings.Split(newClientServer, "\n"), "\r\n")
var cmdbuf strings.Builder
bcmdbuf := bufio.NewWriter(&cmdbuf)
out := func() string {
if err := bcmdbuf.Flush(); err != nil {
t.Errorf("failed to flush: %s", err)
}
return cmdbuf.String()
}
var fake faker
fake.ReadWriter = bufio.NewReadWriter(bufio.NewReader(strings.NewReader(server)), bcmdbuf)
c, err := NewClient(fake, "fake.host")
if err != nil {
t.Fatalf("NewClient: %v\n(after %v)", err, out())
}
defer func() {
_ = c.Close()
}()
c.SetLogAuthData()
if !c.logAuthData {
t.Error("Expected logAuthData to be true but received false")
}
}
var newClientServer = `220 hello world var newClientServer = `220 hello world
250-mx.google.com at your service 250-mx.google.com at your service
250-SIZE 35651584 250-SIZE 35651584
@ -1252,7 +1415,7 @@ func TestHello(t *testing.T) {
case 3: case 3:
c.tls = true c.tls = true
c.serverName = "smtp.google.com" c.serverName = "smtp.google.com"
err = c.Auth(PlainAuth("", "user", "pass", "smtp.google.com")) err = c.Auth(PlainAuth("", "user", "pass", "smtp.google.com", false))
case 4: case 4:
err = c.Mail("test@example.com") err = c.Mail("test@example.com")
case 5: case 5:
@ -1497,7 +1660,7 @@ func TestSendMailWithAuth(t *testing.T) {
} }
}() }()
err = SendMail(l.Addr().String(), PlainAuth("", "user", "pass", "smtp.google.com"), "test@example.com", []string{"other@example.com"}, []byte(strings.Replace(`From: test@example.com err = SendMail(l.Addr().String(), PlainAuth("", "user", "pass", "smtp.google.com", false), "test@example.com", []string{"other@example.com"}, []byte(strings.Replace(`From: test@example.com
To: other@example.com To: other@example.com
Subject: SendMail test Subject: SendMail test
@ -1505,6 +1668,7 @@ SendMail is working for me.
`, "\n", "\r\n", -1))) `, "\n", "\r\n", -1)))
if err == nil { if err == nil {
t.Error("SendMail: Server doesn't support AUTH, expected to get an error, but got none ") t.Error("SendMail: Server doesn't support AUTH, expected to get an error, but got none ")
return
} }
if err.Error() != "smtp: server doesn't support AUTH" { if err.Error() != "smtp: server doesn't support AUTH" {
t.Errorf("Expected: smtp: server doesn't support AUTH, got: %s", err) t.Errorf("Expected: smtp: server doesn't support AUTH, got: %s", err)
@ -1532,7 +1696,7 @@ func TestAuthFailed(t *testing.T) {
c.tls = true c.tls = true
c.serverName = "smtp.google.com" c.serverName = "smtp.google.com"
err = c.Auth(PlainAuth("", "user", "pass", "smtp.google.com")) err = c.Auth(PlainAuth("", "user", "pass", "smtp.google.com", false))
if err == nil { if err == nil {
t.Error("Auth: expected error; got none") t.Error("Auth: expected error; got none")
@ -2137,7 +2301,7 @@ func SkipFlaky(t testing.TB, issue int) {
} }
// testSCRAMSMTPServer represents a test server for SCRAM-based SMTP authentication. // testSCRAMSMTPServer represents a test server for SCRAM-based SMTP authentication.
// It does not do any acutal computation of the challanges but verifies that the expected // It does not do any acutal computation of the challenges but verifies that the expected
// fields are present. We have actual real authentication tests for all SCRAM modes in the // fields are present. We have actual real authentication tests for all SCRAM modes in the
// go-mail client_test.go // go-mail client_test.go
type testSCRAMSMTPServer struct { type testSCRAMSMTPServer struct {

View file

@ -0,0 +1,8 @@
From: §§§§§§§§
To: Mary Smith <mary@example.net>
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".

View file

@ -0,0 +1,3 @@
// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT

8
testdata/RFC5322-A1-1.eml vendored Normal file
View file

@ -0,0 +1,8 @@
From: John Doe <jdoe@machine.example>
To: Mary Smith <mary@example.net>
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".

3
testdata/RFC5322-A1-1.eml.license vendored Normal file
View file

@ -0,0 +1,3 @@
// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT

1
testdata/attachment vendored Normal file
View file

@ -0,0 +1 @@
This is a test attachment

3
testdata/attachment.license vendored Normal file
View file

@ -0,0 +1,3 @@
// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT

1
testdata/attachment.txt vendored Normal file
View file

@ -0,0 +1 @@
This is a test attachment

3
testdata/attachment.txt.license vendored Normal file
View file

@ -0,0 +1,3 @@
// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT

1
testdata/embed.txt vendored Normal file
View file

@ -0,0 +1 @@
This is a test embed

3
testdata/embed.txt.license vendored Normal file
View file

@ -0,0 +1,3 @@
// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT

1
testdata/logo.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

367
testdata/logo.svg.base64 vendored Normal file
View file

@ -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=

3
testdata/logo.svg.base64.license vendored Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 Maria Letta, the go-mail Team
SPDX-License-Identifier: CC-BY-ND-4.0

3
testdata/logo.svg.license vendored Normal file
View file

@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2022 Maria Letta, the go-mail Team
SPDX-License-Identifier: CC-BY-ND-4.0

0
testdata/tmp/.gitkeep vendored Normal file
View file