diff --git a/.cirrus.yml b/.cirrus.yml index f240ff5..8ca7373 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -14,12 +14,10 @@ freebsd_task: image_family: freebsd-14-0 env: - TEST_ALLOW_SEND: 0 TEST_SKIP_SENDMAIL: 1 pkginstall_script: - - pkg update -f - pkg install -y go test_script: - - go test -v -race -cover -shuffle=on ./... \ No newline at end of file + - go test -race -cover -shuffle=on ./... \ No newline at end of file diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index 41a6c27..ed971d1 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -10,7 +10,7 @@ on: paths: - '**.go' - 'go.*' - - '.github/**' + - '.github/workflows/codecov.yml' - 'codecov.yml' pull_request: branches: @@ -18,7 +18,7 @@ on: paths: - '**.go' - 'go.*' - - '.github/**' + - '.github/workflows/codecov.yml' - 'codecov.yml' env: TEST_HOST: ${{ secrets.TEST_HOST }} @@ -27,6 +27,10 @@ env: 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 @@ -36,10 +40,10 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - go: ['1.20', '1.21', '1.22', '1.23'] + go: ['1.23'] steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit @@ -55,9 +59,9 @@ jobs: sudo apt-get -y install sendmail; which sendmail - name: Run Tests run: | - go test -v -race --coverprofile=coverage.coverprofile --covermode=atomic ./... + 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@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0 + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index caab6ba..0a65963 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -45,7 +45,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit @@ -54,7 +54,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/init@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 with: languages: ${{ matrix.language }} # 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). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/autobuild@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 # ℹī¸ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -79,4 +79,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/analyze@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 13ad6e7..9c8db47 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index 8604469..616964e 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit @@ -29,7 +29,7 @@ jobs: go-version: '1.23' - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - name: golangci-lint - uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 + 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 diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 8b1693d..9d5cdfb 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -14,8 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit - name: Run govulncheck - uses: golang/govulncheck-action@dd0578b371c987f96d1185abb54344b44352bd58 # v1.0.3 \ No newline at end of file + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 \ No newline at end of file diff --git a/.github/workflows/offline-tests.yml b/.github/workflows/offline-tests.yml new file mode 100644 index 0000000..2660646 --- /dev/null +++ b/.github/workflows/offline-tests.yml @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2022 Winni Neessen +# +# SPDX-License-Identifier: CC0-1.0 + +name: Offline tests workflow +on: + push: + branches: + - main + paths: + - '**.go' + - 'go.*' + - '.github/workflows/offline-tests.yml' + pull_request: + branches: + - main + paths: + - '**.go' + - 'go.*' + - '.github/workflows/offline-tests.yml' +permissions: + contents: read + +jobs: + run: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + go: ['1.19', '1.20', '1.21', '1.22', '1.23'] + steps: + - name: Harden Runner + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 + with: + egress-policy: audit + + - name: Checkout Code + uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master + - name: Setup go + uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 + with: + go-version: ${{ matrix.go }} + - name: Run Tests + run: | + go test -race -shuffle=on ./... \ No newline at end of file diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index 1897833..04fd414 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 2e0f045..41a963e 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit @@ -67,7 +67,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0 + uses: actions/upload-artifact@604373da6381bf24206979c74d06a550515601b9 # v4.4.1 with: name: SARIF file path: results.sarif @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@4dd16135b69a43b6c8efb853346f8437d92d3c93 # v3.26.6 + uses: github/codeql-action/upload-sarif@c36620d31ac7c881962c3d9dd939c40ec9434f2b # v3.26.12 with: sarif_file: results.sarif diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 1c77858..9b2b899 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -10,24 +10,25 @@ permissions: on: push: branches: - - main # or the name of your main branch + - main + paths: + - '**.go' + - 'go.*' + - '.github/workflows/sonarqube.yml' pull_request: branches: - - main # or the name of your main branch -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" + - 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@5c7944e73c4c2a096b17a9cb74d65b6c2bbafbde # v2.9.1 + uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 with: egress-policy: audit @@ -38,13 +39,13 @@ jobs: - name: Setup Go uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5.0.2 with: - go-version: '1.23.x' + go-version: '1.23' - name: Run unit Tests run: | - go test -v -race --coverprofile=./cov.out ./... + go test -shuffle=on -race --coverprofile=./cov.out ./... - - uses: sonarsource/sonarqube-scan-action@0c0f3958d90fc466625f1d1af1f47bddd4cc6bd1 # master + - uses: sonarsource/sonarqube-scan-action@884b79409bbd464b2a59edc326a4b77dc56b2195 # master env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/.reuse/dep5 b/.reuse/dep5 deleted file mode 100644 index 70261ce..0000000 --- a/.reuse/dep5 +++ /dev/null @@ -1,10 +0,0 @@ -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: go-mail -Upstream-Contact: Winni Neessen -Source: https://github.com/wneessen/go-mail - -# Sample paragraph, commented out: -# -# Files: src/* -# Copyright: $YEAR $NAME <$CONTACT> -# License: ... diff --git a/README.md b/README.md index 260b888..3a67d0d 100644 --- a/README.md +++ b/README.md @@ -18,40 +18,41 @@ SPDX-License-Identifier: CC0-1.0

go-mail logo

-The main idea of this library was to provide a simple interface to sending mails for +The main idea of this library was to provide a simple interface for sending mails to my [JS-Mailer](https://github.com/wneessen/js-mailer) project. It quickly evolved into a full-fledged mail library. -go-mail follows idiomatic Go style and best practice. It's only dependency is the Go Standard Library. It combines a lot -of functionality from the standard library to give easy and convenient access to mail and SMTP related tasks. +go-mail follows idiomatic Go style and best practice. It has a small dependency footprint by mainly relying on the +Go Standard Library and the Go extended packages. It combines a lot of functionality from the standard library to +give easy and convenient access to mail and SMTP related tasks. -Parts of this library (especially some parts of [msgwriter.go](msgwriter.go)) have been forked/ported from the -[go-mail/mail](https://github.com/go-mail/mail) respectively [go-gomail/gomail](https://github.com/go-gomail/gomail) -which both seems to not be maintained anymore. +In the early days, parts of this library (especially some parts of [msgwriter.go](msgwriter.go)) had been +forked/ported from [go-mail/mail](https://github.com/go-mail/mail) and respectively [go-gomail/gomail](https://github.com/go-gomail/gomail). Today +most of the ported code has been refactored. -The smtp package of go-mail is forked from the original Go stdlib's `net/smtp` and then extended by the go-mail -team. +The `smtp` package of go-mail has been forked from the original Go stdlib's `net/smtp` package and has then been extended +by the go-mail team to fit the packages needs (more SMTP Auth methods, logging, concurrency-safety, etc.). ## Features -Some of the features of this library: +Here are some highlights of go-mail's featureset: -* [X] Only Standard Library dependant +* [X] Very small dependency footprint (mainly Go Stdlib and Go extended packages) * [X] Modern, idiomatic Go * [X] Sane and secure defaults * [X] Explicit SSL/TLS support * [X] Implicit StartTLS support with different policies * [X] Makes use of contexts for a better control flow and timeout/cancelation handling -* [X] SMTP Auth support (LOGIN, PLAIN, CRAM-MD, XOAUTH2) +* [X] SMTP Auth support (LOGIN, PLAIN, CRAM-MD, XOAUTH2, SCRAM-SHA-1(-PLUS), SCRAM-SHA-256(-PLUS)) * [X] RFC5322 compliant mail address validation * [X] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, Priority, etc.) -* [X] Reusing the same SMTP connection to send multiple mails +* [X] Concurrency-safe reusing the same SMTP connection to send multiple mails * [X] Support for attachments and inline embeds (from file system, `io.Reader` or `embed.FS`) * [X] Support for different encodings * [X] Middleware support for 3rd-party libraries to alter mail messages * [X] Support sending mails via a local sendmail command * [X] Support for requestng MDNs (RFC 8098) and DSNs (RFC 1891) * [X] DKIM signature support via [go-mail-middlware](https://github.com/wneessen/go-mail-middleware) -* [X] Message object satisfies `io.WriteTo` and `io.Reader` interfaces +* [X] Message object satisfies `io.WriterTo` and `io.Reader` interfaces * [X] Support for Go's `html/template` and `text/template` (as message body, alternative part or attachment/emebed) * [X] Output to file support which allows storing mail messages as e. g. `.eml` files to disk to open them in a MUA * [X] Debug logging of SMTP traffic @@ -76,7 +77,8 @@ We guarantee that go-mail will always support the last four releases of Go. With the user a timeframe of two years to update to the next or even the latest version of Go. ## Support -We have a support and general discussion channel on Discord. Find us at: [#go-mail](https://discord.gg/dbfQyC4s) +We have a support and general discussion channel on Discord. Find us at: [#go-mail](https://discord.gg/dbfQyC4s) alternatively find us +on the [Gophers Slack](https://gophers.slack.com) in #go-mail ## Middleware The goal of go-mail is to keep it free from 3rd party dependencies and only focus on things a mail library should @@ -99,15 +101,18 @@ We provide example code in both our GoDocs as well as on our official Website (s check out our [Getting started](https://go-mail.dev/getting-started/introduction/) guide. ## Authors/Contributors -go-mail was initially authored and developed by [Winni Neessen](https://github.com/wneessen/). +go-mail was initially created and developed by [Winni Neessen](https://github.com/wneessen/), but over time a lot of amazing people +contributed ot the project. Big thanks to all of them for improving the go-mail project (be it writing code, testing +code, reviewing code, writing documenation or helping to translate the website): -Big thanks to the following people, for contributing to the go-mail project (either in form of code or by -reviewing code, writing documenation or helping to translate the website): -* [Christian Vette](https://github.com/cvette) -* [Dhia Gharsallaoui](https://github.com/dhia-gharsallaoui) -* [inliquid](https://github.com/inliquid) -* [iwittkau](https://github.com/iwittkau) -* [James Elliott](https://github.com/james-d-elliott) -* [Maria Letta](https://github.com/MariaLetta) (designed the go-mail logo) -* [Nicola Murino](https://github.com/drakkan) -* [sters](https://github.com/sters) + + + + +A huge thank you also goes to [Maria Letta](https://github.com/MariaLetta) for designing our super cool go-mail logo! + +## Sponsors +We sincerely thank our amazing sponsors for their generous support! Your contributions do not go unnoticed and helps +keeping up the project! + +* [kolaente](https://github.com/kolaente) diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..0bca544 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +# +# SPDX-License-Identifier: MIT + +version = 1 +SPDX-PackageName = "go-mail" +SPDX-PackageSupplier = "Winni Neessen " +SPDX-PackageDownloadLocation = "https://github.com/wneessen/go-mail" +annotations = [] diff --git a/auth.go b/auth.go index 4a59b14..e175a12 100644 --- a/auth.go +++ b/auth.go @@ -6,41 +6,131 @@ package mail import "errors" -// SMTPAuthType represents a string to any SMTP AUTH type +// SMTPAuthType is a type wrapper for a string type. It represents the type of SMTP authentication +// mechanism to be used. type SMTPAuthType string -// Supported SMTP AUTH types const ( - // SMTPAuthCramMD5 is the "CRAM-MD5" SASL authentication mechanism as described in RFC 4954 + // SMTPAuthCramMD5 is the "CRAM-MD5" SASL authentication mechanism as described in RFC 4954. + // https://datatracker.ietf.org/doc/html/rfc4954/ + // + // CRAM-MD5 is not secure by modern standards. The vulnerabilities of MD5 and the lack of + // advanced security features make it inappropriate for protecting sensitive communications + // today. + // + // It was recommended to deprecate the standard in 20 November 2008. As an alternative it + // recommends e.g. SCRAM or SASL Plain protected by TLS instead. + // + // https://datatracker.ietf.org/doc/html/draft-ietf-sasl-crammd5-to-historic-00.html SMTPAuthCramMD5 SMTPAuthType = "CRAM-MD5" - // SMTPAuthLogin is the "LOGIN" SASL authentication mechanism + // SMTPAuthCustom is a custom SMTP AUTH mechanism provided by the user. If a user provides + // a custom smtp.Auth function to the Client, the Client will its smtpAuthType to this type. + // + // Do not use this SMTPAuthType without setting a custom smtp.Auth function on the Client. + SMTPAuthCustom SMTPAuthType = "CUSTOM" + + // SMTPAuthLogin 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 mechansim transmits the username and password in + // plaintext over the internet connection, we only allow this mechanism over a TLS secured + // connection. + // + // https://msopenspecs.azureedge.net/files/MS-XLOGIN/%5bMS-XLOGIN%5d.pdf + // + // https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00 SMTPAuthLogin SMTPAuthType = "LOGIN" // 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 - // authentication, the Client should not be used with the WithSMTPAuth option + // authentication, the Client should not be passed the WithSMTPAuth option at all. SMTPAuthNoAuth SMTPAuthType = "" - // 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 + // plaintext over the internet connection, we only allow this mechanism over a TLS secured + // connection. + // + // https://datatracker.ietf.org/doc/html/rfc4616/ SMTPAuthPlain SMTPAuthType = "PLAIN" // SMTPAuthXOAUTH2 is the "XOAUTH2" SASL authentication mechanism. // https://developers.google.com/gmail/imap/xoauth2-protocol SMTPAuthXOAUTH2 SMTPAuthType = "XOAUTH2" + + // SMTPAuthSCRAMSHA1 is the "SCRAM-SHA-1" SASL authentication mechanism as described in RFC 5802. + // + // SCRAM-SHA-1 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 + // recommended to prefer stronger alternatives like SCRAM-SHA-256(-PLUS), as SHA-1 has known + // vulnerabilities in other contexts, although it remains effective in HMAC constructions. + // + // https://datatracker.ietf.org/doc/html/rfc5802 + SMTPAuthSCRAMSHA1 SMTPAuthType = "SCRAM-SHA-1" + + // SMTPAuthSCRAMSHA1PLUS is the "SCRAM-SHA-1-PLUS" SASL authentication mechanism as described in RFC 5802. + // + // 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 + // process. Therefore we only allow this mechansim over a TLS secured connection. + // + // 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 + // recommended to prefer stronger alternatives like SCRAM-SHA-256(-PLUS), as SHA-1 has known + // vulnerabilities in other contexts, although it remains effective in HMAC constructions. + // + // https://datatracker.ietf.org/doc/html/rfc5802 + SMTPAuthSCRAMSHA1PLUS SMTPAuthType = "SCRAM-SHA-1-PLUS" + + // SMTPAuthSCRAMSHA256 is the "SCRAM-SHA-256" SASL authentication mechanism as described in RFC 7677. + // + // https://datatracker.ietf.org/doc/html/rfc7677 + SMTPAuthSCRAMSHA256 SMTPAuthType = "SCRAM-SHA-256" + + // SMTPAuthSCRAMSHA256PLUS is the "SCRAM-SHA-256-PLUS" SASL authentication mechanism as described in RFC 7677. + // + // 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 + // process. Therefore we only allow this mechansim over a TLS secured connection. + // + // https://datatracker.ietf.org/doc/html/rfc7677 + SMTPAuthSCRAMSHA256PLUS SMTPAuthType = "SCRAM-SHA-256-PLUS" ) // SMTP Auth related static errors var ( - // ErrPlainAuthNotSupported should be used if the target server does not support the "PLAIN" schema + // ErrPlainAuthNotSupported is returned when the server does not support the "PLAIN" SMTP + // authentication type. ErrPlainAuthNotSupported = errors.New("server does not support SMTP AUTH type: PLAIN") - // ErrLoginAuthNotSupported should be used if the target server does not support the "LOGIN" schema + // ErrLoginAuthNotSupported is returned when the server does not support the "LOGIN" SMTP + // authentication type. ErrLoginAuthNotSupported = errors.New("server does not support SMTP AUTH type: LOGIN") - // ErrCramMD5AuthNotSupported should be used if the target server does not support the "CRAM-MD5" schema + // ErrCramMD5AuthNotSupported is returned when the server does not support the "CRAM-MD5" SMTP + // authentication type. ErrCramMD5AuthNotSupported = errors.New("server does not support SMTP AUTH type: CRAM-MD5") - // ErrXOauth2AuthNotSupported should be used if the target server does not support the "XOAUTH2" schema + // ErrXOauth2AuthNotSupported is returned when the server does not support the "XOAUTH2" schema. ErrXOauth2AuthNotSupported = errors.New("server does not support SMTP AUTH type: XOAUTH2") + + // ErrSCRAMSHA1AuthNotSupported is returned when the server does not support the "SCRAM-SHA-1" SMTP + // authentication type. + ErrSCRAMSHA1AuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-1") + + // ErrSCRAMSHA1PLUSAuthNotSupported is returned when the server does not support the "SCRAM-SHA-1-PLUS" SMTP + // authentication type. + ErrSCRAMSHA1PLUSAuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-1-PLUS") + + // ErrSCRAMSHA256AuthNotSupported is returned when the server does not support the "SCRAM-SHA-256" SMTP + // authentication type. + ErrSCRAMSHA256AuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-256") + + // ErrSCRAMSHA256PLUSAuthNotSupported is returned when the server does not support the "SCRAM-SHA-256-PLUS" SMTP + // authentication type. + ErrSCRAMSHA256PLUSAuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-256-PLUS") ) diff --git a/b64linebreaker.go b/b64linebreaker.go index 088b38e..cc83973 100644 --- a/b64linebreaker.go +++ b/b64linebreaker.go @@ -9,21 +9,39 @@ import ( "io" ) -// ErrNoOutWriter is an error message that should be used if a Base64LineBreaker has no out io.Writer set +// newlineBytes is a byte slice representation of the SingleNewLine constant used for line breaking +// in encoding processes. +var newlineBytes = []byte(SingleNewLine) + +// ErrNoOutWriter is the error message returned when no io.Writer is set for Base64LineBreaker. const ErrNoOutWriter = "no io.Writer set for Base64LineBreaker" -// Base64LineBreaker is a io.WriteCloser that writes Base64 encoded data streams -// with line breaks at a given line length +// Base64LineBreaker handles base64 encoding with the insertion of new lines after a certain number +// of characters. +// +// This struct is used to manage base64 encoding while ensuring that new lines are inserted after +// reaching a specific line length. It satisfies the io.WriteCloser interface. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 (Base64 and line length limitations) type Base64LineBreaker struct { line [MaxBodyLength]byte used int out io.Writer } -var newlineBytes = []byte(SingleNewLine) - -// Write writes the data stream and inserts a SingleNewLine when the maximum -// line length is reached +// Write writes data to the Base64LineBreaker, ensuring lines do not exceed MaxBodyLength. +// +// This method writes the provided data to the Base64LineBreaker. It ensures that the written +// lines do not exceed the MaxBodyLength. If the data exceeds the limit, it handles the +// continuation by splitting the data and writing new lines as necessary. +// +// Parameters: +// - data: A byte slice containing the data to be written. +// +// Returns: +// - numBytes: The number of bytes written. +// - err: An error if one occurred during the write operation. func (l *Base64LineBreaker) Write(data []byte) (numBytes int, err error) { if l.out == nil { err = errors.New(ErrNoOutWriter) @@ -55,8 +73,14 @@ func (l *Base64LineBreaker) Write(data []byte) (numBytes int, err error) { return l.Write(data[excess:]) } -// Close closes the Base64LineBreaker and writes any access data that is still -// unwritten in memory +// Close finalizes the Base64LineBreaker, writing any remaining buffered data and appending a newline. +// +// This method ensures that any remaining data in the buffer is written to the output and appends +// a newline. It is used to finalize the Base64LineBreaker and should be called when no more data +// is expected to be written. +// +// Returns: +// - err: An error if one occurred during the final write operation. func (l *Base64LineBreaker) Close() (err error) { if l.used > 0 { _, err = l.out.Write(l.line[0:l.used]) diff --git a/client.go b/client.go index 1d175d1..45fd764 100644 --- a/client.go +++ b/client.go @@ -12,192 +12,248 @@ import ( "net" "os" "strings" + "sync" "time" "github.com/wneessen/go-mail/log" "github.com/wneessen/go-mail/smtp" ) -// Defaults const ( - // DefaultPort is the default connection port to the SMTP server + // DefaultPort is the default connection port to the SMTP server. DefaultPort = 25 - // DefaultPortSSL is the default connection port for SSL/TLS to the SMTP server + // DefaultPortSSL is the default connection port for SSL/TLS to the SMTP server. DefaultPortSSL = 465 - // DefaultPortTLS is the default connection port for STARTTLS to the SMTP server + // DefaultPortTLS is the default connection port for STARTTLS to the SMTP server. DefaultPortTLS = 587 - // DefaultTimeout is the default connection timeout + // DefaultTimeout is the default connection timeout. DefaultTimeout = time.Second * 15 - // DefaultTLSPolicy is the default STARTTLS policy + // DefaultTLSPolicy specifies the default TLS policy for connections. DefaultTLSPolicy = TLSMandatory - // DefaultTLSMinVersion is the minimum TLS version required for the connection - // Nowadays TLS1.2 should be the sane default + // DefaultTLSMinVersion defines the minimum TLS version to be used for secure connections. + // Nowadays TLS 1.2 is assumed be a sane default. DefaultTLSMinVersion = tls.VersionTLS12 ) -// DSNMailReturnOption is a type to define which MAIL RET option is used when a DSN -// is requested -type DSNMailReturnOption string - -// DSNRcptNotifyOption is a type to define which RCPT NOTIFY option is used when a DSN -// is requested -type DSNRcptNotifyOption string - const ( - // DSNMailReturnHeadersOnly requests that only the headers of the message be returned. - // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.3 + // DSNMailReturnHeadersOnly requests that only the message headers of the mail message are returned in + // a DSN (Delivery Status Notification). + // + // https://datatracker.ietf.org/doc/html/rfc1891#section-5.3 DSNMailReturnHeadersOnly DSNMailReturnOption = "HDRS" - // DSNMailReturnFull requests that the entire message be returned in any "failed" - // delivery status notification issued for this recipient - // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.3 + // DSNMailReturnFull requests that the entire mail message is returned in any failed DSN + // (Delivery Status Notification) issued for this recipient. + // + // https://datatracker.ietf.org/doc/html/rfc1891/#section-5.3 DSNMailReturnFull DSNMailReturnOption = "FULL" - // DSNRcptNotifyNever requests that a DSN not be returned to the sender under - // any conditions. - // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + // DSNRcptNotifyNever indicates that no DSN (Delivery Status Notifications) should be sent for the + // recipient under any condition. + // + // https://datatracker.ietf.org/doc/html/rfc1891/#section-5.1 DSNRcptNotifyNever DSNRcptNotifyOption = "NEVER" - // DSNRcptNotifySuccess requests that a DSN be issued on successful delivery - // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + // DSNRcptNotifySuccess indicates that the sender requests a DSN (Delivery Status Notification) if the + // message is successfully delivered. + // + // https://datatracker.ietf.org/doc/html/rfc1891/#section-5.1 DSNRcptNotifySuccess DSNRcptNotifyOption = "SUCCESS" - // DSNRcptNotifyFailure requests that a DSN be issued on delivery failure - // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + // DSNRcptNotifyFailure requests that a DSN (Delivery Status Notification) is issued if delivery of + // a message fails. + // + // https://datatracker.ietf.org/doc/html/rfc1891/#section-5.1 DSNRcptNotifyFailure DSNRcptNotifyOption = "FAILURE" - // DSNRcptNotifyDelay indicates the sender's willingness to receive - // "delayed" DSNs. Delayed DSNs may be issued if delivery of a message has - // been delayed for an unusual amount of time (as determined by the MTA at - // which the message is delayed), but the final delivery status (whether - // successful or failure) cannot be determined. The absence of the DELAY - // keyword in a NOTIFY parameter requests that a "delayed" DSN NOT be - // issued under any conditions. - // See: https://www.rfc-editor.org/rfc/rfc1891#section-5.1 + // DSNRcptNotifyDelay indicates the sender's willingness to receive "delayed" DSNs. + // + // Delayed DSNs may be issued if delivery of a message has been delayed for an unusual amount of time + // (as determined by the MTA at which the message is delayed), but the final delivery status (whether + // successful or failure) cannot be determined. The absence of the DELAY keyword in a NOTIFY parameter + // requests that a "delayed" DSN NOT be issued under any conditions. + // + // https://datatracker.ietf.org/doc/html/rfc1891/#section-5.1 DSNRcptNotifyDelay DSNRcptNotifyOption = "DELAY" ) -// DialContextFunc is a type to define custom DialContext function. -type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error) +type ( + // DialContextFunc defines a function type for establishing a network connection using context, network + // type, and address. It is used to specify custom DialContext function. + // + // By default we use net.Dial or tls.Dial respectively. + DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error) -// Client is the SMTP client struct -type Client struct { - // connection is the net.Conn that the smtp.Client is based on - connection net.Conn + // DSNMailReturnOption is a type wrapper for a string and specifies the type of return content requested + // in a Delivery Status Notification (DSN). + // + // https://datatracker.ietf.org/doc/html/rfc1891/ + DSNMailReturnOption string - // Timeout for the SMTP server connection - connTimeout time.Duration + // DSNRcptNotifyOption is a type wrapper for a string and specifies the notification options for a + // recipient in DSNs. + // + // https://datatracker.ietf.org/doc/html/rfc1891/ + DSNRcptNotifyOption string - // dsn indicates that we want to use DSN for the Client - dsn bool + // Option is a function type that modifies the configuration or behavior of a Client instance. + Option func(*Client) error - // dsnmrtype defines the DSNMailReturnOption in case DSN is enabled - dsnmrtype DSNMailReturnOption + // Client is responsible for connecting and interacting with an SMTP server. + // + // This struct represents the go-mail client, which manages the connection, authentication, and communication + // with an SMTP server. It contains various configuration options, including connection timeouts, encryption + // settings, authentication methods, and Delivery Status Notification (DSN) preferences. + // + // References: + // - https://datatracker.ietf.org/doc/html/rfc3207#section-2 + // - https://datatracker.ietf.org/doc/html/rfc8314 + Client struct { + // connTimeout specifies timeout for the connection to the SMTP server. + connTimeout time.Duration - // dsnrntype defines the DSNRcptNotifyOption in case DSN is enabled - dsnrntype []string + // dialContextFunc is the DialContextFunc that is used by the Client to connect to the SMTP server. + dialContextFunc DialContextFunc - // isEncrypted indicates if a Client connection is encrypted or not - isEncrypted bool + // dsnRcptNotifyType represents the different types of notifications for DSN (Delivery Status Notifications) + // receipts. + dsnRcptNotifyType []string - // noNoop indicates the Noop is to be skipped - noNoop bool + // dsnReturnType specifies the type of Delivery Status Notification (DSN) that should be requested for an + // email. + dsnReturnType DSNMailReturnOption - // HELO/EHLO string for the greeting the target SMTP server - helo string + // fallbackPort is used as an alternative port number in case the primary port is unavailable or + // fails to bind. + // + // The fallbackPort is only used in combination with SetTLSPortPolicy and SetSSLPort correspondingly. + fallbackPort int - // Hostname of the target SMTP server to connect to - host string + // helo is the hostname used in the HELO/EHLO greeting, that is sent to the target SMTP server. + // + // helo might be different as host. This can be useful in a shared-hosting scenario. + helo string - // pass is the corresponding SMTP AUTH password - pass string + // host is the hostname of the SMTP server we are connecting to. + host string - // Port of the SMTP server to connect to - port int - fallbackPort int + // isEncrypted indicates wether the Client connection is encrypted or not. + isEncrypted bool - // smtpAuth is a pointer to smtp.Auth - smtpAuth smtp.Auth + // logger is a logger that satisfies the log.Logger interface. + logger log.Logger - // smtpAuthType represents the authentication type for SMTP AUTH - smtpAuthType SMTPAuthType + // mutex is used to synchronize access to shared resources, ensuring that only one goroutine can + // modify them at a time. + mutex sync.RWMutex - // smtpClient is the smtp.Client that is set up when using the Dial*() methods - smtpClient *smtp.Client + // noNoop indicates that the Client should skip the "NOOP" command during the dial. + // + // This is useful for servers which delay potentially unwanted clients when they perform commands + // other than AUTH. + noNoop bool - // Use SSL for the connection - useSSL bool + // pass represents a password or a secret token used for the SMTP authentication. + pass string - // tlspolicy sets the client to use the provided TLSPolicy for the STARTTLS protocol - tlspolicy TLSPolicy + // port specifies the network port that is used to establish the connection with the SMTP server. + port int - // tlsconfig represents the tls.Config setting for the STARTTLS connection - tlsconfig *tls.Config + // requestDSN indicates wether we want to request DSN (Delivery Status Notifications). + requestDSN bool - // user is the SMTP AUTH username - user string + // smtpAuth is the authentication type that is used to authenticate the user with SMTP server. It + // satisfies the smtp.Auth interface. + // + // Unless you plan to write you own custom authentication method, it is advised to not set this manually. + // You should use one of go-mail's SMTPAuthType, instead. + smtpAuth smtp.Auth - // useDebugLog enables the debug logging on the SMTP client - useDebugLog bool + // smtpAuthType specifies the authentication type to be used for SMTP authentication. + smtpAuthType SMTPAuthType - // logger is a logger that implements the log.Logger interface - logger log.Logger + // smtpClient is an instance of smtp.Client used for handling the communication with the SMTP server. + smtpClient *smtp.Client - // dialContextFunc is a custom DialContext function to dial target SMTP server - dialContextFunc DialContextFunc -} + // tlspolicy defines the TLSPolicy configuration the Client uses for the STARTTLS protocol. + // + // https://datatracker.ietf.org/doc/html/rfc3207#section-2 + tlspolicy TLSPolicy -// Option returns a function that can be used for grouping Client options -type Option func(*Client) error + // tlsconfig is a pointer to tls.Config that specifies the TLS configuration for the STARTTLS communication. + tlsconfig *tls.Config + + // useDebugLog indicates whether debug level logging is enabled for the Client. + useDebugLog bool + + // user represents a username used for the SMTP authentication. + user string + + // useSSL indicates whether to use SSL/TLS encryption for network communication. + // + // https://datatracker.ietf.org/doc/html/rfc8314 + useSSL bool + } +) var ( - // ErrInvalidPort should be used if a port is specified that is not valid + // ErrInvalidPort is returned when the specified port for the SMTP connection is not valid ErrInvalidPort = errors.New("invalid port number") - // ErrInvalidTimeout should be used if a timeout is set that is zero or negative + // ErrInvalidTimeout is returned when the specified timeout is zero or negative. ErrInvalidTimeout = errors.New("timeout cannot be zero or negative") - // ErrInvalidHELO should be used if an empty HELO sting is provided + // ErrInvalidHELO is returned when the HELO/EHLO value is invalid due to being empty. ErrInvalidHELO = errors.New("invalid HELO/EHLO value - must not be empty") - // ErrInvalidTLSConfig should be used if an empty tls.Config is provided + // ErrInvalidTLSConfig is returned when the provided TLS configuration is invalid or nil. ErrInvalidTLSConfig = errors.New("invalid TLS config") - // ErrNoHostname should be used if a Client has no hostname set + // ErrNoHostname is returned when the hostname for the client is not provided or empty. ErrNoHostname = errors.New("hostname for client cannot be empty") - // ErrDeadlineExtendFailed should be used if the extension of the connection deadline fails + // ErrDeadlineExtendFailed is returned when an attempt to extend the connection deadline fails. ErrDeadlineExtendFailed = errors.New("connection deadline extension failed") - // ErrNoActiveConnection should be used when a method is used that requies a server connection - // but is not yet connected + // ErrNoActiveConnection indicates that there is no active connection to the SMTP server. ErrNoActiveConnection = errors.New("not connected to SMTP server") - // ErrServerNoUnencoded should be used when 8BIT encoding is selected for a message, but - // the server does not offer 8BITMIME mode + // ErrServerNoUnencoded indicates that the server does not support 8BITMIME for unencoded 8-bit messages. ErrServerNoUnencoded = errors.New("message is 8bit unencoded, but server does not support 8BITMIME") - // ErrInvalidDSNMailReturnOption should be used when an invalid option is provided for the - // DSNMailReturnOption in WithDSN + // ErrInvalidDSNMailReturnOption is returned when an invalid DSNMailReturnOption is provided as argument + // to the WithDSN Option. ErrInvalidDSNMailReturnOption = errors.New("DSN mail return option can only be HDRS or FULL") - // ErrInvalidDSNRcptNotifyOption should be used when an invalid option is provided for the - // DSNRcptNotifyOption in WithDSN + // ErrInvalidDSNRcptNotifyOption is returned when an invalid DSNRcptNotifyOption is provided as argument + // to the WithDSN Option. ErrInvalidDSNRcptNotifyOption = errors.New("DSN rcpt notify option can only be: NEVER, " + "SUCCESS, FAILURE or DELAY") - // ErrInvalidDSNRcptNotifyCombination should be used when an invalid option is provided for the - // DSNRcptNotifyOption in WithDSN + // ErrInvalidDSNRcptNotifyCombination is returned when an invalid combination of DSNRcptNotifyOption is + // provided as argument to the WithDSN Option. ErrInvalidDSNRcptNotifyCombination = errors.New("DSN rcpt notify option NEVER cannot be " + "combined with any of SUCCESS, FAILURE or DELAY") ) -// NewClient returns a new Session client object +// NewClient creates a new Client instance with the provided host and optional configuration Option functions. +// +// This function initializes a Client with default values, such as connection timeout, port, TLS settings, +// and the HELO/EHLO hostname. Option functions, if provided, can override the default configuration. +// It ensures that essential values, like the host, are set. An error is returned if critical defaults are unset. +// +// Parameters: +// - host: The hostname of the SMTP server to connect to. +// - opts: Optional configuration functions to override default settings. +// +// Returns: +// - A pointer to the initialized Client. +// - An error if any critical default values are missing or options fail to apply. func NewClient(host string, opts ...Option) (*Client, error) { c := &Client{ connTimeout: DefaultTimeout, @@ -230,7 +286,17 @@ func NewClient(host string, opts ...Option) (*Client, error) { return c, nil } -// WithPort overrides the default connection port +// WithPort sets the port number for the Client and overrides the default port. +// +// This function sets the specified port number for the Client, ensuring that the port number is valid +// (between 1 and 65535). If the provided port number is invalid, an error is returned. +// +// Parameters: +// - port: The port number to be used by the Client. Must be between 1 and 65535. +// +// Returns: +// - An Option function that applies the port setting to the Client. +// - An error if the port number is outside the valid range. func WithPort(port int) Option { return func(c *Client) error { if port < 1 || port > 65535 { @@ -241,7 +307,17 @@ func WithPort(port int) Option { } } -// WithTimeout overrides the default connection timeout +// WithTimeout sets the connection timeout for the Client and overrides the default timeout. +// +// This function configures the Client with a specified connection timeout duration. It validates that the +// provided timeout is greater than zero. If the timeout is invalid, an error is returned. +// +// Parameters: +// - timeout: The duration to be set as the connection timeout. Must be greater than zero. +// +// Returns: +// - An Option function that applies the timeout setting to the Client. +// - An error if the timeout duration is invalid. func WithTimeout(timeout time.Duration) Option { return func(c *Client) error { if timeout <= 0 { @@ -252,7 +328,12 @@ func WithTimeout(timeout time.Duration) Option { } } -// WithSSL tells the client to use a SSL/TLS connection +// WithSSL enables implicit SSL/TLS for the Client. +// +// This function configures the Client to use implicit SSL/TLS for secure communication. +// +// Returns: +// - An Option function that enables SSL/TLS for the Client. func WithSSL() Option { return func(c *Client) error { c.useSSL = true @@ -260,16 +341,19 @@ func WithSSL() Option { } } -// WithSSLPort tells the Client wether or not to use SSL and fallback. -// The correct port is automatically set. +// WithSSLPort enables implicit SSL/TLS with an optional fallback for the Client. The correct port is +// automatically set. // -// Port 465 is used when SSL set (true). -// Port 25 is used when SSL is unset (false). -// When the SSL connection fails and fb is set to true, -// the client will attempt to connect on port 25 using plaintext. +// When this option is used with NewClient, the default port 25 is overridden with port 465 for SSL/TLS connections. +// If fallback is set to true and the SSL/TLS connection fails, the Client attempts to connect on port 25 using an +// unencrypted connection. If WithPort has already been used to set a different port, that port takes precedence, +// and the automatic fallback mechanism is skipped. // -// Note: If a different port has already been set otherwise, the port-choosing -// and fallback automatism will be skipped. +// Parameters: +// - fallback: A boolean indicating whether to fall back to port 25 without SSL/TLS if the connection fails. +// +// Returns: +// - An Option function that enables SSL/TLS and configures the fallback mechanism for the Client. func WithSSLPort(fallback bool) Option { return func(c *Client) error { c.SetSSLPort(true, fallback) @@ -277,8 +361,15 @@ func WithSSLPort(fallback bool) Option { } } -// WithDebugLog tells the client to log incoming and outgoing messages of the SMTP client -// to StdErr +// WithDebugLog enables debug logging for the Client. +// +// 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 +// unencrypted authentication data, depending on the SMTP authentication method in use, which could +// pose a data protection risk. +// +// Returns: +// - An Option function that enables debug logging for the Client. func WithDebugLog() Option { return func(c *Client) error { c.useDebugLog = true @@ -286,7 +377,16 @@ func WithDebugLog() Option { } } -// WithLogger overrides the default log.Logger that is used for debug logging +// WithLogger defines a custom logger for the Client. +// +// This function sets a custom logger for the Client, which must satisfy the log.Logger interface. The custom +// logger is used only when debug logging is enabled. By default, log.Stdlog is used if no custom logger is provided. +// +// Parameters: +// - logger: A logger that satisfies the log.Logger interface. +// +// Returns: +// - An Option function that sets the custom logger for the Client. func WithLogger(logger log.Logger) Option { return func(c *Client) error { c.logger = logger @@ -294,7 +394,17 @@ func WithLogger(logger log.Logger) Option { } } -// WithHELO tells the client to use the provided string as HELO/EHLO greeting host +// WithHELO sets the HELO/EHLO string used by the Client. +// +// This function configures the HELO/EHLO string sent by the Client when initiating communication +// with the SMTP server. By default, os.Hostname is used to identify the HELO/EHLO string. +// +// Parameters: +// - helo: The string to be used for the HELO/EHLO greeting. Must not be empty. +// +// Returns: +// - An Option function that sets the HELO/EHLO string for the Client. +// - An error if the provided HELO string is empty. func WithHELO(helo string) Option { return func(c *Client) error { if helo == "" { @@ -305,10 +415,19 @@ func WithHELO(helo string) Option { } } -// WithTLSPolicy tells the client to use the provided TLSPolicy +// WithTLSPolicy sets the TLSPolicy of the Client and overrides the DefaultTLSPolicy. // -// Note: To follow best-practices for SMTP TLS connections, it is recommended -// to use WithTLSPortPolicy instead. +// This function configures the Client's TLSPolicy, specifying how the Client handles TLS for SMTP connections. +// It overrides the default policy. For best practices regarding SMTP TLS connections, it is recommended to use +// WithTLSPortPolicy instead. +// +// Parameters: +// - policy: The TLSPolicy to be applied to the Client. +// +// Returns: +// - An Option function that sets the TLSPolicy for the Client. +// +// WithTLSPortPolicy instead. func WithTLSPolicy(policy TLSPolicy) Option { return func(c *Client) error { c.tlspolicy = policy @@ -316,16 +435,20 @@ func WithTLSPolicy(policy TLSPolicy) Option { } } -// WithTLSPortPolicy tells the client to use the provided TLSPolicy, -// The correct port is automatically set. +// WithTLSPortPolicy enables explicit TLS via STARTTLS for the Client using the provided TLSPolicy. The +// correct port is automatically set. // -// Port 587 is used for TLSMandatory and TLSOpportunistic. -// If the connection fails with TLSOpportunistic, -// a plaintext connection is attempted on port 25 as a fallback. -// NoTLS will allways use port 25. +// When TLSMandatory or TLSOpportunistic is provided as the TLSPolicy, port 587 is used for the connection. +// If the connection fails with TLSOpportunistic, the Client attempts to connect on port 25 using an unencrypted +// connection as a fallback. If NoTLS is specified, the Client will always use port 25. +// If WithPort has already been used to set a different port, that port takes precedence, and the automatic fallback +// mechanism is skipped. // -// Note: If a different port has already been set otherwise, the port-choosing -// and fallback automatism will be skipped. +// Parameters: +// - policy: The TLSPolicy to be used for STARTTLS communication. +// +// Returns: +// - An Option function that sets the TLSPortPolicy for the Client. func WithTLSPortPolicy(policy TLSPolicy) Option { return func(c *Client) error { c.SetTLSPortPolicy(policy) @@ -333,7 +456,17 @@ func WithTLSPortPolicy(policy TLSPolicy) Option { } } -// WithTLSConfig tells the client to use the provided *tls.Config +// WithTLSConfig sets the tls.Config for the Client and overrides the default configuration. +// +// This function configures the Client with a custom tls.Config. It overrides the default TLS settings. +// An error is returned if the provided tls.Config is nil or invalid. +// +// Parameters: +// - tlsconfig: A pointer to a tls.Config struct to be used for the Client. Must not be nil. +// +// Returns: +// - An Option function that sets the tls.Config for the Client. +// - An error if the provided tls.Config is invalid. func WithTLSConfig(tlsconfig *tls.Config) Option { return func(c *Client) error { if tlsconfig == nil { @@ -344,7 +477,15 @@ func WithTLSConfig(tlsconfig *tls.Config) Option { } } -// WithSMTPAuth tells the client to use the provided SMTPAuthType for authentication +// WithSMTPAuth configures the Client to use the specified SMTPAuthType for SMTP authentication. +// +// This function sets the Client to use the specified SMTPAuthType for authenticating with the SMTP server. +// +// Parameters: +// - authtype: The SMTPAuthType to be used for SMTP authentication. +// +// Returns: +// - An Option function that configures the Client to use the specified SMTPAuthType. func WithSMTPAuth(authtype SMTPAuthType) Option { return func(c *Client) error { c.smtpAuthType = authtype @@ -352,15 +493,33 @@ func WithSMTPAuth(authtype SMTPAuthType) Option { } } -// WithSMTPAuthCustom tells the client to use the provided smtp.Auth for SMTP authentication +// WithSMTPAuthCustom sets a custom SMTP authentication mechanism for the Client. +// +// This function configures the Client to use a custom SMTP authentication mechanism. The provided +// mechanism must satisfy the smtp.Auth interface. +// +// Parameters: +// - smtpAuth: The custom SMTP authentication mechanism, which must implement the smtp.Auth interface. +// +// Returns: +// - An Option function that sets the custom SMTP authentication for the Client. func WithSMTPAuthCustom(smtpAuth smtp.Auth) Option { return func(c *Client) error { c.smtpAuth = smtpAuth + c.smtpAuthType = SMTPAuthCustom return nil } } -// WithUsername tells the client to use the provided string as username for authentication +// WithUsername sets the username that the Client will use for SMTP authentication. +// +// This function configures the Client with the specified username for SMTP authentication. +// +// Parameters: +// - username: The username to be used for SMTP authentication. +// +// Returns: +// - An Option function that sets the username for the Client. func WithUsername(username string) Option { return func(c *Client) error { c.user = username @@ -368,7 +527,15 @@ func WithUsername(username string) Option { } } -// WithPassword tells the client to use the provided string as password/secret for authentication +// WithPassword sets the password that the Client will use for SMTP authentication. +// +// This function configures the Client with the specified password for SMTP authentication. +// +// Parameters: +// - password: The password to be used for SMTP authentication. +// +// Returns: +// - An Option function that sets the password for the Client. func WithPassword(password string) Option { return func(c *Client) error { c.pass = password @@ -376,23 +543,41 @@ func WithPassword(password string) Option { } } -// WithDSN enables the Client to request DSNs (if the server supports it) -// as described in the RFC 1891 and set defaults for DSNMailReturnOption -// to DSNMailReturnFull and DSNRcptNotifyOption to DSNRcptNotifySuccess -// and DSNRcptNotifyFailure +// WithDSN enables DSN (Delivery Status Notifications) for the Client as described in RFC 1891. +// +// This function configures the Client to request DSN, which provides status notifications for email delivery. +// DSN is only effective if the SMTP server supports it. By default, DSNMailReturnOption is set to DSNMailReturnFull, +// and DSNRcptNotifyOption is set to DSNRcptNotifySuccess and DSNRcptNotifyFailure. +// +// Returns: +// - An Option function that enables DSN for the Client. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc1891 func WithDSN() Option { return func(c *Client) error { - c.dsn = true - c.dsnmrtype = DSNMailReturnFull - c.dsnrntype = []string{string(DSNRcptNotifyFailure), string(DSNRcptNotifySuccess)} + c.requestDSN = true + c.dsnReturnType = DSNMailReturnFull + c.dsnRcptNotifyType = []string{string(DSNRcptNotifyFailure), string(DSNRcptNotifySuccess)} return nil } } -// WithDSNMailReturnType enables the Client to request DSNs (if the server supports it) -// as described in the RFC 1891 and set the MAIL FROM Return option type to the -// given DSNMailReturnOption -// See: https://www.rfc-editor.org/rfc/rfc1891 +// WithDSNMailReturnType enables DSN (Delivery Status Notifications) for the Client as described in RFC 1891. +// +// This function configures the Client to request DSN and sets the DSNMailReturnOption to the provided value. +// DSN is only effective if the SMTP server supports it. The provided option must be either DSNMailReturnHeadersOnly +// or DSNMailReturnFull; otherwise, an error is returned. +// +// Parameters: +// - option: The DSNMailReturnOption to be used (DSNMailReturnHeadersOnly or DSNMailReturnFull). +// +// Returns: +// - An Option function that sets the DSNMailReturnOption for the Client. +// - An error if an invalid DSNMailReturnOption is provided. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc1891 func WithDSNMailReturnType(option DSNMailReturnOption) Option { return func(c *Client) error { switch option { @@ -402,15 +587,28 @@ func WithDSNMailReturnType(option DSNMailReturnOption) Option { return ErrInvalidDSNMailReturnOption } - c.dsn = true - c.dsnmrtype = option + c.requestDSN = true + c.dsnReturnType = option return nil } } -// WithDSNRcptNotifyType enables the Client to request DSNs as described in the RFC 1891 -// and sets the RCPT TO notify options to the given list of DSNRcptNotifyOption -// See: https://www.rfc-editor.org/rfc/rfc1891 +// WithDSNRcptNotifyType enables DSN (Delivery Status Notifications) for the Client as described in RFC 1891. +// +// This function configures the Client to request DSN and sets the DSNRcptNotifyOption to the provided values. +// The provided options must be valid DSNRcptNotifyOption types. If DSNRcptNotifyNever is combined with +// any other notification type (such as DSNRcptNotifySuccess, DSNRcptNotifyFailure, or DSNRcptNotifyDelay), +// an error is returned. +// +// Parameters: +// - opts: A variadic list of DSNRcptNotifyOption values (e.g., DSNRcptNotifySuccess, DSNRcptNotifyFailure). +// +// Returns: +// - An Option function that sets the DSNRcptNotifyOption for the Client. +// - An error if invalid DSNRcptNotifyOption values are provided or incompatible combinations are used. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc1891 func WithDSNRcptNotifyType(opts ...DSNRcptNotifyOption) Option { return func(c *Client) error { var rcptOpts []string @@ -436,14 +634,19 @@ func WithDSNRcptNotifyType(opts ...DSNRcptNotifyOption) Option { return ErrInvalidDSNRcptNotifyCombination } - c.dsn = true - c.dsnrntype = rcptOpts + c.requestDSN = true + c.dsnRcptNotifyType = rcptOpts return nil } } -// WithoutNoop disables the Client Noop check during connections. This is primarily for servers which delay responses -// to SMTP commands that are not the AUTH command. For example Microsoft Exchange's Tarpit. +// WithoutNoop indicates that the Client should skip the "NOOP" command during the dial. +// +// This option is useful for servers that delay potentially unwanted clients when they perform +// commands other than AUTH, such as Microsoft's Exchange Tarpit. +// +// Returns: +// - An Option function that configures the Client to skip the "NOOP" command. func WithoutNoop() Option { return func(c *Client) error { c.noNoop = true @@ -451,7 +654,16 @@ func WithoutNoop() Option { } } -// WithDialContextFunc overrides the default DialContext for connecting SMTP server +// WithDialContextFunc sets the provided DialContextFunc as the DialContext for connecting to the SMTP server. +// +// This function overrides the default DialContext function used by the Client when establishing a connection +// to the SMTP server with the provided DialContextFunc. +// +// Parameters: +// - dialCtxFunc: The custom DialContextFunc to be used for connecting to the SMTP server. +// +// Returns: +// - An Option function that sets the custom DialContextFunc for the Client. func WithDialContextFunc(dialCtxFunc DialContextFunc) Option { return func(c *Client) error { c.dialContextFunc = dialCtxFunc @@ -459,34 +671,50 @@ func WithDialContextFunc(dialCtxFunc DialContextFunc) Option { } } -// TLSPolicy returns the currently set TLSPolicy as 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. +// +// Returns: +// - A string representing the currently set TLSPolicy for the Client. func (c *Client) TLSPolicy() string { return c.tlspolicy.String() } -// ServerAddr returns the currently set combination of hostname and port +// ServerAddr returns the server address that is currently set on the Client in the format "host:port". +// +// This method constructs and returns the server address using the host and port currently configured +// for the Client. +// +// Returns: +// - A string representing the server address in the format "host:port". func (c *Client) ServerAddr() string { return fmt.Sprintf("%s:%d", c.host, c.port) } -// SetTLSPolicy overrides the current TLSPolicy with the given TLSPolicy value +// SetTLSPolicy sets or overrides the TLSPolicy currently configured on the Client with the given TLSPolicy. // -// Note: To follow best-practices for SMTP TLS connections, it is recommended -// to use SetTLSPortPolicy instead. +// This method allows the user to set a new TLSPolicy for the Client. For best practices regarding +// SMTP TLS connections, it is recommended to use SetTLSPortPolicy instead. +// +// Parameters: +// - policy: The TLSPolicy to be set for the Client. func (c *Client) SetTLSPolicy(policy TLSPolicy) { c.tlspolicy = policy } -// SetTLSPortPolicy overrides the current TLSPolicy with the given TLSPolicy -// value. The correct port is automatically set. +// SetTLSPortPolicy sets or overrides the TLSPolicy currently configured on the Client with the given TLSPolicy. +// The correct port is automatically set based on the specified policy. // -// Port 587 is used for TLSMandatory and TLSOpportunistic. -// If the connection fails with TLSOpportunistic, a plaintext connection is -// attempted on port 25 as a fallback. -// NoTLS will allways use port 25. +// If TLSMandatory or TLSOpportunistic is provided as the TLSPolicy, port 587 will be used for the connection. +// If the connection fails with TLSOpportunistic, the Client will attempt to connect on port 25 using +// an unencrypted connection as a fallback. If NoTLS is provided, the Client will always use port 25. // -// Note: If a different port has already been set otherwise, the port-choosing -// and fallback automatism will be skipped. +// Note: If a different port has already been set using WithPort, that port takes precedence and is used +// to establish the SSL/TLS connection, skipping the automatic fallback mechanism. +// +// Parameters: +// - policy: The TLSPolicy to be set for the Client. func (c *Client) SetTLSPortPolicy(policy TLSPolicy) { if c.port == DefaultPort { c.port = DefaultPortTLS @@ -502,21 +730,29 @@ func (c *Client) SetTLSPortPolicy(policy TLSPolicy) { c.tlspolicy = policy } -// SetSSL tells the Client wether to use SSL or not +// SetSSL sets or overrides whether the Client should use implicit SSL/TLS. +// +// This method configures the Client to either enable or disable implicit SSL/TLS for secure communication. +// +// Parameters: +// - ssl: A boolean value indicating whether to enable (true) or disable (false) implicit SSL/TLS. func (c *Client) SetSSL(ssl bool) { c.useSSL = ssl } -// SetSSLPort tells the Client wether or not to use SSL and fallback. +// SetSSLPort sets or overrides whether the Client should use implicit SSL/TLS with optional fallback. // The correct port is automatically set. // -// Port 465 is used when SSL set (true). -// Port 25 is used when SSL is unset (false). -// When the SSL connection fails and fb is set to true, -// the client will attempt to connect on port 25 using plaintext. +// If ssl is set to true, the default port 25 will be overridden with port 465. If fallback is set to true +// and the SSL/TLS connection fails, the Client will attempt to connect on port 25 using an unencrypted +// connection. // -// Note: If a different port has already been set otherwise, the port-choosing -// and fallback automatism will be skipped. +// Note: If a different port has already been set using WithPort, that port takes precedence and is used +// to establish the SSL/TLS connection, skipping the automatic fallback mechanism. +// +// Parameters: +// - ssl: A boolean value indicating whether to enable implicit SSL/TLS. +// - fallback: A boolean value indicating whether to enable fallback to an unencrypted connection. func (c *Client) SetSSLPort(ssl bool, fallback bool) { if c.port == DefaultPort { if ssl { @@ -532,7 +768,15 @@ func (c *Client) SetSSLPort(ssl bool, fallback bool) { c.useSSL = ssl } -// SetDebugLog tells the Client whether debug logging is enabled or not +// SetDebugLog sets or overrides whether the Client is using debug logging. The debug logger will log incoming +// and outgoing communication between the Client and the server to os.Stderr. +// +// Note: The SMTP communication might include unencrypted authentication data, depending on whether you are using +// SMTP authentication and the type of authentication mechanism. This could pose a data protection risk. Use +// debug logging with caution. +// +// Parameters: +// - val: A boolean value indicating whether to enable (true) or disable (false) debug logging. func (c *Client) SetDebugLog(val bool) { c.useDebugLog = val if c.smtpClient != nil { @@ -540,7 +784,14 @@ func (c *Client) SetDebugLog(val bool) { } } -// SetLogger tells the Client which log.Logger to use +// SetLogger sets or overrides the custom logger currently used by the Client. The logger must +// satisfy the log.Logger interface and is only utilized when debug logging is enabled on the +// Client. +// +// By default, log.Stdlog is used if no custom logger is provided. +// +// Parameters: +// - logger: A logger that satisfies the log.Logger interface to be set for the Client. func (c *Client) SetLogger(logger log.Logger) { c.logger = logger if c.smtpClient != nil { @@ -548,7 +799,17 @@ func (c *Client) SetLogger(logger log.Logger) { } } -// SetTLSConfig overrides the current *tls.Config with the given *tls.Config value +// SetTLSConfig sets or overrides the tls.Config currently configured for the Client with the +// given value. An error is returned if the provided tls.Config is invalid. +// +// This method ensures that the provided tls.Config is not nil before updating the Client's +// TLS configuration. +// +// Parameters: +// - tlsconfig: A pointer to the tls.Config struct to be set for the Client. Must not be nil. +// +// Returns: +// - An error if the provided tls.Config is invalid or nil. func (c *Client) SetTLSConfig(tlsconfig *tls.Config) error { if tlsconfig == nil { return ErrInvalidTLSConfig @@ -557,38 +818,72 @@ func (c *Client) SetTLSConfig(tlsconfig *tls.Config) error { return nil } -// SetUsername overrides the current username string with the given value +// SetUsername sets or overrides the username that the Client will use for SMTP authentication. +// +// This method updates the username used by the Client for authenticating with the SMTP server. +// +// Parameters: +// - username: The username to be set for SMTP authentication. func (c *Client) SetUsername(username string) { c.user = username } -// SetPassword overrides the current password string with the given value +// SetPassword sets or overrides the password that the Client will use for SMTP authentication. +// +// This method updates the password used by the Client for authenticating with the SMTP server. +// +// Parameters: +// - password: The password to be set for SMTP authentication. func (c *Client) SetPassword(password string) { c.pass = password } -// SetSMTPAuth overrides the current SMTP AUTH type setting with the given value +// SetSMTPAuth sets or overrides the SMTPAuthType currently configured on the Client for SMTP +// authentication. +// +// This method updates the authentication type used by the Client for authenticating with the +// SMTP server and resets any custom SMTP authentication mechanism. +// +// Parameters: +// - authtype: The SMTPAuthType to be set for the Client. func (c *Client) SetSMTPAuth(authtype SMTPAuthType) { c.smtpAuthType = authtype + c.smtpAuth = nil } -// SetSMTPAuthCustom overrides the current SMTP AUTH setting with the given custom smtp.Auth +// SetSMTPAuthCustom sets or overrides the custom SMTP authentication mechanism currently +// configured for the Client. The provided authentication mechanism must satisfy the +// smtp.Auth interface. +// +// This method updates the authentication mechanism used by the Client for authenticating +// with the SMTP server and sets the authentication type to SMTPAuthCustom. +// +// Parameters: +// - smtpAuth: The custom SMTP authentication mechanism to be set for the Client. func (c *Client) SetSMTPAuthCustom(smtpAuth smtp.Auth) { c.smtpAuth = smtpAuth + c.smtpAuthType = SMTPAuthCustom } -// setDefaultHelo retrieves the current hostname and sets it as HELO/EHLO hostname -func (c *Client) setDefaultHelo() error { - hostname, err := os.Hostname() - if err != nil { - return fmt.Errorf("failed to read local hostname: %w", err) - } - c.helo = hostname - return nil -} - -// DialWithContext establishes a connection to the SMTP server with a given 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 +// before connecting to the server. After dialing the defined DialContextFunc and successfully +// establishing the connection, it sends the HELO/EHLO SMTP command, followed by optional +// STARTTLS and SMTP AUTH commands. If debug logging is enabled, it attaches the log.Logger. +// +// After this method is called, the Client will have an active (cancelable) connection to the +// SMTP server. +// +// Parameters: +// - dialCtx: The context.Context used to control the connection timeout and cancellation. +// +// Returns: +// - An error if the connection to the SMTP server fails or any subsequent command fails. func (c *Client) DialWithContext(dialCtx context.Context) error { + c.mutex.Lock() + defer c.mutex.Unlock() + ctx, cancel := context.WithDeadline(dialCtx, time.Now().Add(c.connTimeout)) defer cancel() @@ -602,17 +897,16 @@ func (c *Client) DialWithContext(dialCtx context.Context) error { c.dialContextFunc = tlsDialer.DialContext } } - var err error - c.connection, err = c.dialContextFunc(ctx, "tcp", c.ServerAddr()) + connection, err := c.dialContextFunc(ctx, "tcp", c.ServerAddr()) if err != nil && c.fallbackPort != 0 { // TODO: should we somehow log or append the previous error? - c.connection, err = c.dialContextFunc(ctx, "tcp", c.serverFallbackAddr()) + connection, err = c.dialContextFunc(ctx, "tcp", c.serverFallbackAddr()) } if err != nil { return err } - client, err := smtp.NewClient(c.connection, c.host) + client, err := smtp.NewClient(connection, c.host) if err != nil { return err } @@ -642,10 +936,18 @@ func (c *Client) DialWithContext(dialCtx context.Context) error { return nil } -// Close closes the Client connection +// Close terminates the connection to the SMTP server, returning an error if the disconnection +// fails. If the connection is already closed, this method is a no-op and disregards any error. +// +// This function checks if the Client's SMTP connection is active. If not, it simply returns +// without any action. If the connection is active, it attempts to gracefully close the +// connection using the Quit method. +// +// Returns: +// - An error if the disconnection fails; otherwise, returns nil. func (c *Client) Close() error { - if err := c.checkConn(); err != nil { - return err + if !c.smtpClient.HasConnection() { + return nil } if err := c.smtpClient.Quit(); err != nil { return fmt.Errorf("failed to close SMTP client: %w", err) @@ -654,7 +956,14 @@ func (c *Client) Close() error { return nil } -// Reset sends the RSET command to the SMTP client +// Reset sends an SMTP RSET command to reset the state of the current SMTP session. +// +// This method checks the connection to the SMTP server and, if the connection is valid, +// it sends an RSET command to reset the session state. If the connection is invalid or +// the command fails, an error is returned. +// +// Returns: +// - An error if the connection check fails or if sending the RSET command fails; otherwise, returns nil. func (c *Client) Reset() error { if err := c.checkConn(); err != nil { return err @@ -666,19 +975,46 @@ func (c *Client) Reset() error { return nil } -// DialAndSend establishes a connection to the SMTP server with a -// default context.Background and sends the mail +// DialAndSend establishes a connection to the server and sends out the provided Msg. +// It calls DialAndSendWithContext with an empty Context.Background. +// +// This method simplifies the process of connecting to the SMTP server and sending messages +// by using a default context. It prepares the messages for sending and ensures the connection +// is established before attempting to send them. +// +// Parameters: +// - messages: A variadic list of pointers to Msg objects to be sent. +// +// Returns: +// - An error if the connection fails or if sending the messages fails; otherwise, returns nil. func (c *Client) DialAndSend(messages ...*Msg) error { ctx := context.Background() return c.DialAndSendWithContext(ctx, messages...) } -// DialAndSendWithContext establishes a connection to the SMTP server with a -// custom context and sends the mail +// DialAndSendWithContext establishes a connection to the SMTP server using DialWithContext +// with the provided context.Context, then sends out the given Msg. After successful delivery, +// the Client will close the connection to the server. +// +// This method first attempts to connect to the SMTP server using the provided context. +// Upon successful connection, it sends the specified messages and ensures that the connection +// is closed after the operation, regardless of success or failure in sending the messages. +// +// Parameters: +// - ctx: The context.Context to control the connection timeout and cancellation. +// - messages: A variadic list of pointers to Msg objects to be sent. +// +// Returns: +// - An error if the connection fails, if sending the messages fails, or if closing the +// connection fails; otherwise, returns nil. func (c *Client) DialAndSendWithContext(ctx context.Context, messages ...*Msg) error { if err := c.DialWithContext(ctx); err != nil { return fmt.Errorf("dial failed: %w", err) } + defer func() { + _ = c.Close() + }() + if err := c.Send(messages...); err != nil { return fmt.Errorf("send failed: %w", err) } @@ -688,67 +1024,23 @@ func (c *Client) DialAndSendWithContext(ctx context.Context, messages ...*Msg) e return nil } -// checkConn makes sure that a required server connection is available and extends the -// connection deadline -func (c *Client) checkConn() error { - if c.connection == nil { - return ErrNoActiveConnection - } - - if !c.noNoop { - if err := c.smtpClient.Noop(); err != nil { - return ErrNoActiveConnection - } - } - - if err := c.connection.SetDeadline(time.Now().Add(c.connTimeout)); err != nil { - return ErrDeadlineExtendFailed - } - return nil -} - -// serverFallbackAddr returns the currently set combination of hostname -// and fallback port. -func (c *Client) serverFallbackAddr() string { - return fmt.Sprintf("%s:%d", c.host, c.fallbackPort) -} - -// tls tries to make sure that the STARTTLS requirements are satisfied -func (c *Client) tls() error { - if c.connection == nil { - return ErrNoActiveConnection - } - if !c.useSSL && c.tlspolicy != NoTLS { - hasStartTLS := false - extension, _ := c.smtpClient.Extension("STARTTLS") - if c.tlspolicy == TLSMandatory { - hasStartTLS = true - if !extension { - return fmt.Errorf("STARTTLS mode set to: %q, but target host does not support STARTTLS", - c.tlspolicy) - } - } - if c.tlspolicy == TLSOpportunistic { - if extension { - hasStartTLS = true - } - } - if hasStartTLS { - if err := c.smtpClient.StartTLS(c.tlsconfig); err != nil { - return err - } - } - _, c.isEncrypted = c.smtpClient.TLSConnectionState() - } - return nil -} - -// auth will try to perform SMTP AUTH if requested +// auth attempts to authenticate the client using SMTP AUTH mechanisms. It checks the connection, +// determines the supported authentication methods, and applies the appropriate authentication +// type. An error is returned if authentication fails. +// +// This method first verifies the connection to the SMTP server. If no custom authentication +// mechanism is provided, it checks which authentication methods are supported by the server. +// Based on the configured SMTPAuthType, it sets up the appropriate authentication mechanism. +// Finally, it attempts to authenticate the client using the selected method. +// +// Returns: +// - An error if the connection check fails, if no supported authentication method is found, +// or if the authentication process fails. func (c *Client) auth() error { if err := c.checkConn(); err != nil { return fmt.Errorf("failed to authenticate: %w", err) } - if c.smtpAuth == nil && c.smtpAuthType != "" { + if c.smtpAuth == nil && c.smtpAuthType != SMTPAuthCustom { hasSMTPAuth, smtpAuthType := c.smtpClient.Extension("AUTH") if !hasSMTPAuth { return fmt.Errorf("server does not support SMTP AUTH") @@ -775,6 +1067,34 @@ func (c *Client) auth() error { return ErrXOauth2AuthNotSupported } c.smtpAuth = smtp.XOAuth2Auth(c.user, c.pass) + case SMTPAuthSCRAMSHA1: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA1)) { + return ErrSCRAMSHA1AuthNotSupported + } + c.smtpAuth = smtp.ScramSHA1Auth(c.user, c.pass) + case SMTPAuthSCRAMSHA256: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA256)) { + return ErrSCRAMSHA256AuthNotSupported + } + c.smtpAuth = smtp.ScramSHA256Auth(c.user, c.pass) + case SMTPAuthSCRAMSHA1PLUS: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA1PLUS)) { + return ErrSCRAMSHA1PLUSAuthNotSupported + } + tlsConnState, err := c.smtpClient.GetTLSConnectionState() + if err != nil { + return err + } + c.smtpAuth = smtp.ScramSHA1PlusAuth(c.user, c.pass, tlsConnState) + case SMTPAuthSCRAMSHA256PLUS: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA256PLUS)) { + return ErrSCRAMSHA256PLUSAuthNotSupported + } + tlsConnState, err := c.smtpClient.GetTLSConnectionState() + if err != nil { + return err + } + c.smtpAuth = smtp.ScramSHA256PlusAuth(c.user, c.pass, tlsConnState) default: return fmt.Errorf("unsupported SMTP AUTH type %q", c.smtpAuthType) } @@ -787,3 +1107,225 @@ func (c *Client) auth() error { } return nil } + +// sendSingleMsg sends out a single message and returns an error if the transmission or +// delivery fails. It is invoked by the public Send methods. +// +// This method handles the process of sending a single email message through the SMTP +// client. It performs several checks and operations, including verifying the encoding, +// retrieving the sender and recipient addresses, and managing delivery status notifications +// (DSN). It attempts to send the message and handles any errors that occur during the +// transmission process, ensuring that any necessary cleanup is performed (such as resetting +// the SMTP client if an error occurs). +// +// Parameters: +// - message: A pointer to the Msg object representing the email message to be sent. +// +// Returns: +// - An error if any part of the sending process fails; otherwise, returns nil. +func (c *Client) sendSingleMsg(message *Msg) error { + c.mutex.Lock() + defer c.mutex.Unlock() + + if message.encoding == NoEncoding { + if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok { + return &SendError{Reason: ErrNoUnencoded, isTemp: false, affectedMsg: message} + } + } + from, err := message.GetSender(false) + if err != nil { + return &SendError{ + Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + rcpts, err := message.GetRecipients() + if err != nil { + return &SendError{ + Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + + if c.requestDSN { + if c.dsnReturnType != "" { + c.smtpClient.SetDSNMailReturnOption(string(c.dsnReturnType)) + } + } + if err = c.smtpClient.Mail(from); err != nil { + retError := &SendError{ + Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { + retError.errlist = append(retError.errlist, resetSendErr) + } + return retError + } + hasError := false + rcptSendErr := &SendError{affectedMsg: message} + rcptSendErr.errlist = make([]error, 0) + rcptSendErr.rcpt = make([]string, 0) + rcptNotifyOpt := strings.Join(c.dsnRcptNotifyType, ",") + c.smtpClient.SetDSNRcptNotifyOption(rcptNotifyOpt) + for _, rcpt := range rcpts { + if err = c.smtpClient.Rcpt(rcpt); err != nil { + rcptSendErr.Reason = ErrSMTPRcptTo + rcptSendErr.errlist = append(rcptSendErr.errlist, err) + rcptSendErr.rcpt = append(rcptSendErr.rcpt, rcpt) + rcptSendErr.isTemp = isTempError(err) + hasError = true + } + } + if hasError { + if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { + rcptSendErr.errlist = append(rcptSendErr.errlist, resetSendErr) + } + return rcptSendErr + } + writer, err := c.smtpClient.Data() + if err != nil { + return &SendError{ + Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + _, err = message.WriteTo(writer) + if err != nil { + return &SendError{ + Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + message.isDelivered = true + + if err = writer.Close(); err != nil { + return &SendError{ + Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + + if err = c.Reset(); err != nil { + return &SendError{ + Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + if err = c.checkConn(); err != nil { + return &SendError{ + Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err), + affectedMsg: message, + } + } + return nil +} + +// checkConn ensures that a required server connection is available and extends the connection +// deadline. +// +// This method verifies whether there is an active connection to the SMTP server. If there is no +// connection, it returns an error. If the "noNoop" flag is not set, it sends a NOOP command to +// the server to confirm the connection is still valid. Finally, it updates the connection +// deadline based on the specified timeout value. If any operation fails, the appropriate error +// is returned. +// +// Returns: +// - An error if there is no active connection, if the NOOP command fails, or if extending +// the deadline fails; otherwise, returns nil. +func (c *Client) checkConn() error { + if !c.smtpClient.HasConnection() { + return ErrNoActiveConnection + } + + if !c.noNoop { + if err := c.smtpClient.Noop(); err != nil { + return ErrNoActiveConnection + } + } + + if err := c.smtpClient.UpdateDeadline(c.connTimeout); err != nil { + return ErrDeadlineExtendFailed + } + return nil +} + +// serverFallbackAddr returns the currently set combination of hostname and fallback port. +// +// This method constructs and returns the server address using the host and fallback port +// currently configured for the Client. It is useful for establishing a connection when +// the primary port is unavailable. +// +// Returns: +// - A string representing the server address in the format "host:fallbackPort". +func (c *Client) serverFallbackAddr() string { + return fmt.Sprintf("%s:%d", c.host, c.fallbackPort) +} + +// setDefaultHelo sets the HELO/EHLO hostname to the local machine's hostname. +// +// This method retrieves the local hostname using the operating system's hostname function +// and sets it as the HELO/EHLO string for the Client. If retrieving the hostname fails, +// an error is returned. +// +// Returns: +// - An error if there is a failure in reading the local hostname; otherwise, returns nil. +func (c *Client) setDefaultHelo() error { + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("failed to read local hostname: %w", err) + } + c.helo = hostname + return nil +} + +// tls establishes a TLS connection based on the client's TLS policy and configuration. +// Returns an error if no active connection exists or if a TLS error occurs. +// +// This method first checks if there is an active connection to the SMTP server. If SSL is not +// being used and the TLS policy is not set to NoTLS, it checks for STARTTLS support. Depending +// on the TLS policy (mandatory or opportunistic), it may initiate a TLS connection using the +// StartTLS method. The method also retrieves the TLS connection state to determine if the +// connection is encrypted and returns any errors encountered during these processes. +// +// Returns: +// - An error if there is no active connection, if STARTTLS is required but not supported, +// or if there are issues during the TLS handshake; otherwise, returns nil. +func (c *Client) tls() error { + if !c.smtpClient.HasConnection() { + return ErrNoActiveConnection + } + if !c.useSSL && c.tlspolicy != NoTLS { + hasStartTLS := false + extension, _ := c.smtpClient.Extension("STARTTLS") + if c.tlspolicy == TLSMandatory { + hasStartTLS = true + if !extension { + return fmt.Errorf("STARTTLS mode set to: %q, but target host does not support STARTTLS", + c.tlspolicy) + } + } + if c.tlspolicy == TLSOpportunistic { + if extension { + hasStartTLS = true + } + } + if hasStartTLS { + if err := c.smtpClient.StartTLS(c.tlsconfig); err != nil { + return err + } + } + tlsConnState, err := c.smtpClient.GetTLSConnectionState() + if err != nil { + switch { + case errors.Is(err, smtp.ErrNonTLSConnection): + c.isEncrypted = false + return nil + default: + return fmt.Errorf("failed to get TLS connection state: %w", err) + } + } + c.isEncrypted = tlsConnState.HandshakeComplete + } + return nil +} diff --git a/client_119.go b/client_119.go index 52e4b3f..093967e 100644 --- a/client_119.go +++ b/client_119.go @@ -7,111 +7,39 @@ package mail -import "strings" +import "errors" -// Send sends out the mail message +// Send attempts to send one or more Msg using the Client connection to the SMTP server. +// If the Client has no active connection to the server, Send will fail with an error. For each +// of the provided Msg, it will associate a SendError with the Msg in case of a transmission +// or delivery error. +// +// This method first checks for an active connection to the SMTP server. If the connection is +// not valid, it returns a SendError. It then iterates over the provided messages, attempting +// to send each one. If an error occurs during sending, the method records the error and +// associates it with the corresponding Msg. If multiple errors are encountered, it aggregates +// them into a single SendError to be returned. +// +// Parameters: +// - messages: A variadic list of pointers to Msg objects to be sent. +// +// Returns: +// - An error that represents the sending result, which may include multiple SendErrors if +// any occurred; otherwise, returns nil. func (c *Client) Send(messages ...*Msg) error { - if cerr := c.checkConn(); cerr != nil { - return &SendError{Reason: ErrConnCheck, errlist: []error{cerr}, isTemp: isTempError(cerr)} + if err := c.checkConn(); err != nil { + return &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)} } var errs []*SendError - for _, message := range messages { - message.sendError = nil - if message.encoding == NoEncoding { - if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok { - sendErr := &SendError{Reason: ErrNoUnencoded, isTemp: false} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - } - from, err := message.GetSender(false) - if err != nil { - sendErr := &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - rcpts, err := message.GetRecipients() - if err != nil { - sendErr := &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } + for id, message := range messages { + if sendErr := c.sendSingleMsg(message); sendErr != nil { + messages[id].sendError = sendErr - if c.dsn { - if c.dsnmrtype != "" { - c.smtpClient.SetDSNMailReturnOption(string(c.dsnmrtype)) + var msgSendErr *SendError + if errors.As(sendErr, &msgSendErr) { + errs = append(errs, msgSendErr) } } - if err = c.smtpClient.Mail(from); err != nil { - sendErr := &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err)} - if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { - sendErr.errlist = append(sendErr.errlist, resetSendErr) - } - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - failed := false - rcptSendErr := &SendError{} - rcptSendErr.errlist = make([]error, 0) - rcptSendErr.rcpt = make([]string, 0) - rcptNotifyOpt := strings.Join(c.dsnrntype, ",") - c.smtpClient.SetDSNRcptNotifyOption(rcptNotifyOpt) - for _, rcpt := range rcpts { - if err = c.smtpClient.Rcpt(rcpt); err != nil { - rcptSendErr.Reason = ErrSMTPRcptTo - rcptSendErr.errlist = append(rcptSendErr.errlist, err) - rcptSendErr.rcpt = append(rcptSendErr.rcpt, rcpt) - rcptSendErr.isTemp = isTempError(err) - failed = true - } - } - if failed { - if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { - rcptSendErr.errlist = append(rcptSendErr.errlist, err) - } - message.sendError = rcptSendErr - errs = append(errs, rcptSendErr) - continue - } - writer, err := c.smtpClient.Data() - if err != nil { - sendErr := &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - _, err = message.WriteTo(writer) - if err != nil { - sendErr := &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - message.isDelivered = true - - if err = writer.Close(); err != nil { - sendErr := &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - - if err = c.Reset(); err != nil { - sendErr := &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } - if err = c.checkConn(); err != nil { - sendErr := &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)} - message.sendError = sendErr - errs = append(errs, sendErr) - continue - } } if len(errs) > 0 { diff --git a/client_120.go b/client_120.go index ed80fee..012a4f7 100644 --- a/client_120.go +++ b/client_120.go @@ -9,101 +9,38 @@ package mail import ( "errors" - "strings" ) -// Send sends out the mail message +// Send attempts to send one or more Msg using the Client connection to the SMTP server. +// If the Client has no active connection to the server, Send will fail with an error. For each +// of the provided Msg, it will associate a SendError with the Msg in case of a transmission +// or delivery error. +// +// This method first checks for an active connection to the SMTP server. If the connection is +// not valid, it returns an error wrapped in a SendError. It then iterates over the provided +// messages, attempting to send each one. If an error occurs during sending, the method records +// the error and associates it with the corresponding Msg. +// +// Parameters: +// - messages: A variadic list of pointers to Msg objects to be sent. +// +// Returns: +// - An error that aggregates any SendErrors encountered during the sending process; otherwise, returns nil. func (c *Client) Send(messages ...*Msg) (returnErr error) { if err := c.checkConn(); err != nil { returnErr = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)} return } - for _, message := range messages { - message.sendError = nil - if message.encoding == NoEncoding { - if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok { - message.sendError = &SendError{Reason: ErrNoUnencoded, isTemp: false} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - } - from, err := message.GetSender(false) - if err != nil { - message.sendError = &SendError{Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - rcpts, err := message.GetRecipients() - if err != nil { - message.sendError = &SendError{Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - if c.dsn { - if c.dsnmrtype != "" { - c.smtpClient.SetDSNMailReturnOption(string(c.dsnmrtype)) - } - } - if err = c.smtpClient.Mail(from); err != nil { - message.sendError = &SendError{Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { - returnErr = errors.Join(returnErr, resetSendErr) - } - continue - } - failed := false - rcptSendErr := &SendError{} - rcptSendErr.errlist = make([]error, 0) - rcptSendErr.rcpt = make([]string, 0) - rcptNotifyOpt := strings.Join(c.dsnrntype, ",") - c.smtpClient.SetDSNRcptNotifyOption(rcptNotifyOpt) - for _, rcpt := range rcpts { - if err = c.smtpClient.Rcpt(rcpt); err != nil { - rcptSendErr.Reason = ErrSMTPRcptTo - rcptSendErr.errlist = append(rcptSendErr.errlist, err) - rcptSendErr.rcpt = append(rcptSendErr.rcpt, rcpt) - rcptSendErr.isTemp = isTempError(err) - failed = true - } - } - if failed { - if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil { - returnErr = errors.Join(returnErr, resetSendErr) - } - message.sendError = rcptSendErr - returnErr = errors.Join(returnErr, message.sendError) - continue - } - writer, err := c.smtpClient.Data() - if err != nil { - message.sendError = &SendError{Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - _, err = message.WriteTo(writer) - if err != nil { - message.sendError = &SendError{Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - message.isDelivered = true + var errs []error + defer func() { + returnErr = errors.Join(errs...) + }() - if err = writer.Close(); err != nil { - message.sendError = &SendError{Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - - if err = c.Reset(); err != nil { - message.sendError = &SendError{Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) - continue - } - if err = c.checkConn(); err != nil { - message.sendError = &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)} - returnErr = errors.Join(returnErr, message.sendError) + for id, message := range messages { + if sendErr := c.sendSingleMsg(message); sendErr != nil { + messages[id].sendError = sendErr + errs = append(errs, sendErr) } } diff --git a/client_test.go b/client_test.go index 0ad309a..c767e75 100644 --- a/client_test.go +++ b/client_test.go @@ -5,6 +5,7 @@ package mail import ( + "bufio" "context" "crypto/tls" "errors" @@ -14,6 +15,7 @@ import ( "os" "strconv" "strings" + "sync" "testing" "time" @@ -21,11 +23,18 @@ import ( "github.com/wneessen/go-mail/smtp" ) -// DefaultHost is used as default hostname for the Client -const DefaultHost = "localhost" - -// TestRcpt -const TestRcpt = "go-mail@mytrashmailer.com" +const ( + // DefaultHost is used as default hostname for the Client + DefaultHost = "localhost" + // TestRcpt is a trash mail address to send test mails to + TestRcpt = "couttifaddebro-1473@yopmail.com" + // TestServerProto is the protocol used for the simple SMTP test server + TestServerProto = "tcp" + // TestServerAddr is the address the simple SMTP test server listens on + TestServerAddr = "127.0.0.1" + // TestServerPortBase is the base port for the simple SMTP test server + TestServerPortBase = 2025 +) // TestNewClient tests the NewClient() method with its default options func TestNewClient(t *testing.T) { @@ -474,20 +483,20 @@ func TestWithDSN(t *testing.T) { t.Errorf("failed to create new client: %s", err) return } - if !c.dsn { - t.Errorf("WithDSN failed. c.dsn expected to be: %t, got: %t", true, c.dsn) + if !c.requestDSN { + t.Errorf("WithDSN failed. c.requestDSN expected to be: %t, got: %t", true, c.requestDSN) } - if c.dsnmrtype != DSNMailReturnFull { - t.Errorf("WithDSN failed. c.dsnmrtype expected to be: %s, got: %s", DSNMailReturnFull, - c.dsnmrtype) + if c.dsnReturnType != DSNMailReturnFull { + t.Errorf("WithDSN failed. c.dsnReturnType expected to be: %s, got: %s", DSNMailReturnFull, + c.dsnReturnType) } - if c.dsnrntype[0] != string(DSNRcptNotifyFailure) { - t.Errorf("WithDSN failed. c.dsnrntype[0] expected to be: %s, got: %s", DSNRcptNotifyFailure, - c.dsnrntype[0]) + if c.dsnRcptNotifyType[0] != string(DSNRcptNotifyFailure) { + t.Errorf("WithDSN failed. c.dsnRcptNotifyType[0] expected to be: %s, got: %s", DSNRcptNotifyFailure, + c.dsnRcptNotifyType[0]) } - if c.dsnrntype[1] != string(DSNRcptNotifySuccess) { - t.Errorf("WithDSN failed. c.dsnrntype[1] expected to be: %s, got: %s", DSNRcptNotifySuccess, - c.dsnrntype[1]) + if c.dsnRcptNotifyType[1] != string(DSNRcptNotifySuccess) { + t.Errorf("WithDSN failed. c.dsnRcptNotifyType[1] expected to be: %s, got: %s", DSNRcptNotifySuccess, + c.dsnRcptNotifyType[1]) } } @@ -510,8 +519,8 @@ func TestWithDSNMailReturnType(t *testing.T) { t.Errorf("failed to create new client: %s", err) return } - if string(c.dsnmrtype) != tt.want { - t.Errorf("WithDSNMailReturnType failed. Expected %s, got: %s", tt.want, string(c.dsnmrtype)) + if string(c.dsnReturnType) != tt.want { + t.Errorf("WithDSNMailReturnType failed. Expected %s, got: %s", tt.want, string(c.dsnReturnType)) } }) } @@ -538,11 +547,11 @@ func TestWithDSNRcptNotifyType(t *testing.T) { t.Errorf("failed to create new client: %s", err) return } - if len(c.dsnrntype) <= 0 && !tt.sf { + if len(c.dsnRcptNotifyType) <= 0 && !tt.sf { t.Errorf("WithDSNRcptNotifyType failed. Expected at least one DSNRNType but got none") } - if !tt.sf && c.dsnrntype[0] != tt.want { - t.Errorf("WithDSNRcptNotifyType failed. Expected %s, got: %s", tt.want, c.dsnrntype[0]) + if !tt.sf && c.dsnRcptNotifyType[0] != tt.want { + t.Errorf("WithDSNRcptNotifyType failed. Expected %s, got: %s", tt.want, c.dsnRcptNotifyType[0]) } }) } @@ -593,6 +602,10 @@ func TestSetSMTPAuthCustom(t *testing.T) { if c.smtpAuth == nil { t.Errorf("failed to set custom SMTP auth method. SMTP Auth method is empty") } + if c.smtpAuthType != SMTPAuthCustom { + t.Errorf("failed to set custom SMTP auth method. SMTP Auth type is not custom: %s", + c.smtpAuthType) + } p, _, err := c.smtpAuth.Start(&si) if err != nil { t.Errorf("SMTP Auth Start() method returned error: %s", err) @@ -604,6 +617,32 @@ func TestSetSMTPAuthCustom(t *testing.T) { } } +// TestClient_Close_double tests if a close on an already closed connection causes an error. +func TestClient_Close_double(t *testing.T) { + c, err := getTestConnection(true) + if err != nil { + t.Skipf("failed to create test client: %s. Skipping tests", err) + } + ctx := context.Background() + if err = c.DialWithContext(ctx); err != nil { + t.Errorf("failed to dial with context: %s", err) + return + } + if c.smtpClient == nil { + t.Errorf("DialWithContext didn't fail but no SMTP client found.") + return + } + if !c.smtpClient.HasConnection() { + t.Errorf("DialWithContext didn't fail but no connection found.") + } + if err = c.Close(); err != nil { + t.Errorf("failed to close connection: %s", err) + } + if err = c.Close(); err != nil { + t.Errorf("failed 2nd close connection: %s", err) + } +} + // TestClient_DialWithContext tests the DialWithContext method for the Client object func TestClient_DialWithContext(t *testing.T) { c, err := getTestConnection(true) @@ -611,15 +650,16 @@ func TestClient_DialWithContext(t *testing.T) { t.Skipf("failed to create test client: %s. Skipping tests", err) } ctx := context.Background() - if err := c.DialWithContext(ctx); err != nil { + if err = c.DialWithContext(ctx); err != nil { t.Errorf("failed to dial with context: %s", err) return } - if c.connection == nil { - t.Errorf("DialWithContext didn't fail but no connection found.") - } if c.smtpClient == nil { t.Errorf("DialWithContext didn't fail but no SMTP client found.") + return + } + if !c.smtpClient.HasConnection() { + t.Errorf("DialWithContext didn't fail but no connection found.") } if err := c.Close(); err != nil { t.Errorf("failed to close connection: %s", err) @@ -629,24 +669,25 @@ func TestClient_DialWithContext(t *testing.T) { // TestClient_DialWithContext_Fallback tests the Client.DialWithContext method with the fallback // port functionality func TestClient_DialWithContext_Fallback(t *testing.T) { - c, err := getTestConnection(true) + c, err := getTestConnectionNoTestPort(true) if err != nil { t.Skipf("failed to create test client: %s. Skipping tests", err) } c.SetTLSPortPolicy(TLSOpportunistic) c.port = 999 ctx := context.Background() - if err := c.DialWithContext(ctx); err != nil { + if err = c.DialWithContext(ctx); err != nil { t.Errorf("failed to dial with context: %s", err) return } - if c.connection == nil { - t.Errorf("DialWithContext didn't fail but no connection found.") - } if c.smtpClient == nil { t.Errorf("DialWithContext didn't fail but no SMTP client found.") + return } - if err := c.Close(); err != nil { + if !c.smtpClient.HasConnection() { + t.Errorf("DialWithContext didn't fail but no connection found.") + } + if err = c.Close(); err != nil { t.Errorf("failed to close connection: %s", err) } @@ -666,18 +707,19 @@ func TestClient_DialWithContext_Debug(t *testing.T) { t.Skipf("failed to create test client: %s. Skipping tests", err) } ctx := context.Background() - if err := c.DialWithContext(ctx); err != nil { + if err = c.DialWithContext(ctx); err != nil { t.Errorf("failed to dial with context: %s", err) return } - if c.connection == nil { - t.Errorf("DialWithContext didn't fail but no connection found.") - } if c.smtpClient == nil { t.Errorf("DialWithContext didn't fail but no SMTP client found.") + return + } + if !c.smtpClient.HasConnection() { + t.Errorf("DialWithContext didn't fail but no connection found.") } c.SetDebugLog(true) - if err := c.Close(); err != nil { + if err = c.Close(); err != nil { t.Errorf("failed to close connection: %s", err) } } @@ -690,19 +732,20 @@ func TestClient_DialWithContext_Debug_custom(t *testing.T) { t.Skipf("failed to create test client: %s. Skipping tests", err) } ctx := context.Background() - if err := c.DialWithContext(ctx); err != nil { + if err = c.DialWithContext(ctx); err != nil { t.Errorf("failed to dial with context: %s", err) return } - if c.connection == nil { - t.Errorf("DialWithContext didn't fail but no connection found.") - } if c.smtpClient == nil { t.Errorf("DialWithContext didn't fail but no SMTP client found.") + return + } + if !c.smtpClient.HasConnection() { + t.Errorf("DialWithContext didn't fail but no connection found.") } c.SetDebugLog(true) c.SetLogger(log.New(os.Stderr, log.LevelDebug)) - if err := c.Close(); err != nil { + if err = c.Close(); err != nil { t.Errorf("failed to close connection: %s", err) } } @@ -714,10 +757,9 @@ func TestClient_DialWithContextInvalidHost(t *testing.T) { if err != nil { t.Skipf("failed to create test client: %s. Skipping tests", err) } - c.connection = nil c.host = "invalid.addr" ctx := context.Background() - if err := c.DialWithContext(ctx); err == nil { + if err = c.DialWithContext(ctx); err == nil { t.Errorf("dial succeeded but was supposed to fail") return } @@ -730,10 +772,9 @@ func TestClient_DialWithContextInvalidHELO(t *testing.T) { if err != nil { t.Skipf("failed to create test client: %s. Skipping tests", err) } - c.connection = nil c.helo = "" ctx := context.Background() - if err := c.DialWithContext(ctx); err == nil { + if err = c.DialWithContext(ctx); err == nil { t.Errorf("dial succeeded but was supposed to fail") return } @@ -750,7 +791,7 @@ func TestClient_DialWithContextInvalidAuth(t *testing.T) { c.pass = "invalid" c.SetSMTPAuthCustom(smtp.LoginAuth("invalid", "invalid", "invalid")) ctx := context.Background() - if err := c.DialWithContext(ctx); err == nil { + if err = c.DialWithContext(ctx); err == nil { t.Errorf("dial succeeded but was supposed to fail") return } @@ -762,8 +803,7 @@ func TestClient_checkConn(t *testing.T) { if err != nil { t.Skipf("failed to create test client: %s. Skipping tests", err) } - c.connection = nil - if err := c.checkConn(); err == nil { + if err = c.checkConn(); err == nil { t.Errorf("connCheck() should fail but succeeded") } } @@ -794,21 +834,23 @@ func TestClient_DialWithContextOptions(t *testing.T) { } ctx := context.Background() - if err := c.DialWithContext(ctx); err != nil && !tt.sf { + if err = c.DialWithContext(ctx); err != nil && !tt.sf { t.Errorf("failed to dial with context: %s", err) return } if !tt.sf { - if c.connection == nil && !tt.sf { - t.Errorf("DialWithContext didn't fail but no connection found.") - } if c.smtpClient == nil && !tt.sf { t.Errorf("DialWithContext didn't fail but no SMTP client found.") + return } - if err := c.Reset(); err != nil { + if !c.smtpClient.HasConnection() && !tt.sf { + t.Errorf("DialWithContext didn't fail but no connection found.") + return + } + if err = c.Reset(); err != nil { t.Errorf("failed to reset connection: %s", err) } - if err := c.Close(); err != nil { + if err = c.Close(); err != nil { t.Errorf("failed to close connection: %s", err) } } @@ -1003,17 +1045,15 @@ func TestClient_DialSendCloseBroken(t *testing.T) { } if tt.closestart { _ = c.smtpClient.Close() - _ = c.connection.Close() } - if err := c.Send(m); err != nil && !tt.sf { + if err = c.Send(m); err != nil && !tt.sf { t.Errorf("Send() failed: %s", err) return } if tt.closeearly { _ = c.smtpClient.Close() - _ = c.connection.Close() } - if err := c.Close(); err != nil && !tt.sf { + if err = c.Close(); err != nil && !tt.sf { t.Errorf("Close() failed: %s", err) return } @@ -1063,17 +1103,15 @@ func TestClient_DialSendCloseBrokenWithDSN(t *testing.T) { } if tt.closestart { _ = c.smtpClient.Close() - _ = c.connection.Close() } - if err := c.Send(m); err != nil && !tt.sf { + if err = c.Send(m); err != nil && !tt.sf { t.Errorf("Send() failed: %s", err) return } if tt.closeearly { _ = c.smtpClient.Close() - _ = c.connection.Close() } - if err := c.Close(); err != nil && !tt.sf { + if err = c.Close(); err != nil && !tt.sf { t.Errorf("Close() failed: %s", err) return } @@ -1251,6 +1289,876 @@ func TestClient_DialAndSendWithContext_withSendError(t *testing.T) { } } +func TestClient_SendErrorNoEncoding(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + featureSet := "250-AUTH PLAIN\r\n250-DSN\r\n250 SMTPUTF8" + serverPort := TestServerPortBase + 1 + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("valid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "Test body") + message.SetMessageIDWithValue("this.is.a.message.id") + message.SetEncoding(NoEncoding) + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrNoUnencoded { + t.Errorf("expected ErrNoUnencoded error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + if sendErr.Msg() == nil { + t.Errorf("expected message to be set, but got nil") + } + } + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_SendErrorMailFrom(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 2 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("invalid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "Test body") + message.SetMessageIDWithValue("this.is.a.message.id") + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrSMTPMailFrom { + t.Errorf("expected ErrSMTPMailFrom error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + if sendErr.Msg() == nil { + t.Errorf("expected message to be set, but got nil") + } + } + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_SendErrorMailFromReset(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 3 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("invalid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "Test body") + message.SetMessageIDWithValue("this.is.a.message.id") + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrSMTPMailFrom { + t.Errorf("expected ErrSMTPMailFrom error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + if len(sendErr.errlist) != 2 { + t.Errorf("expected 2 errors, but got %d", len(sendErr.errlist)) + return + } + if !strings.EqualFold(sendErr.errlist[0].Error(), "503 5.1.2 Invalid from: ") { + t.Errorf("expected error: %q, but got %q", + "503 5.1.2 Invalid from: ", sendErr.errlist[0].Error()) + } + if !strings.EqualFold(sendErr.errlist[1].Error(), "500 5.1.2 Error: reset failed") { + t.Errorf("expected error: %q, but got %q", + "500 5.1.2 Error: reset failed", sendErr.errlist[1].Error()) + } + } + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_SendErrorToReset(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 4 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("valid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("invalid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "Test body") + message.SetMessageIDWithValue("this.is.a.message.id") + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrSMTPRcptTo { + t.Errorf("expected ErrSMTPRcptTo error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + if len(sendErr.errlist) != 2 { + t.Errorf("expected 2 errors, but got %d", len(sendErr.errlist)) + return + } + if !strings.EqualFold(sendErr.errlist[0].Error(), "500 5.1.2 Invalid to: ") { + t.Errorf("expected error: %q, but got %q", + "500 5.1.2 Invalid to: ", sendErr.errlist[0].Error()) + } + if !strings.EqualFold(sendErr.errlist[1].Error(), "500 5.1.2 Error: reset failed") { + t.Errorf("expected error: %q, but got %q", + "500 5.1.2 Error: reset failed", sendErr.errlist[1].Error()) + } + } + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_SendErrorDataClose(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 5 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("valid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "DATA close should fail") + message.SetMessageIDWithValue("this.is.a.message.id") + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrSMTPDataClose { + t.Errorf("expected ErrSMTPDataClose error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + } + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_SendErrorDataWrite(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 6 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("valid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "DATA write should fail") + message.SetMessageIDWithValue("this.is.a.message.id") + message.SetGenHeader("X-Test-Header", "DATA write should fail") + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrSMTPDataClose { + t.Errorf("expected ErrSMTPDataClose error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + } +} + +func TestClient_SendErrorReset(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 7 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + message := NewMsg() + if err := message.From("valid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "Test body") + message.SetMessageIDWithValue("this.is.a.message.id") + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Send(message); err == nil { + t.Error("expected Send() to fail but didn't") + } + + var sendErr *SendError + if !errors.As(err, &sendErr) { + t.Errorf("expected *SendError type as returned error, but got %T", sendErr) + } + if errors.As(err, &sendErr) { + if sendErr.IsTemp() { + t.Errorf("expected permanent error but IsTemp() returned true") + } + if sendErr.Reason != ErrSMTPReset { + t.Errorf("expected ErrSMTPReset error, but got %s", sendErr.Reason) + } + if !strings.EqualFold(sendErr.MessageID(), "") { + t.Errorf("expected message ID: %q, but got %q", "", + sendErr.MessageID()) + } + } + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_DialSendConcurrent_online(t *testing.T) { + if os.Getenv("TEST_ALLOW_SEND") == "" { + t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") + } + + client, err := getTestConnection(true) + if err != nil { + t.Skipf("failed to create test client: %s. Skipping tests", err) + } + + var messages []*Msg + for i := 0; i < 10; i++ { + message := NewMsg() + if err := message.FromFormat("go-mail Test Mailer", os.Getenv("TEST_FROM")); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To(TestRcpt); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject(fmt.Sprintf("Test subject for mail %d", i)) + message.SetBodyString(TypeTextPlain, fmt.Sprintf("This is the test body of the mail no. %d", i)) + message.SetMessageID() + messages = append(messages, message) + } + + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + + wg := sync.WaitGroup{} + for id, message := range messages { + wg.Add(1) + go func(curMsg *Msg, curID int) { + defer wg.Done() + if goroutineErr := client.Send(curMsg); err != nil { + t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr) + } + }(message, id) + } + wg.Wait() + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_DialSendConcurrent_local(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 20 + featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 500) + + client, err := NewClient(TestServerAddr, WithPort(serverPort), + WithTLSPortPolicy(NoTLS), WithSMTPAuth(SMTPAuthPlain), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + } + + var messages []*Msg + for i := 0; i < 20; i++ { + message := NewMsg() + if err := message.From("valid-from@domain.tld"); err != nil { + t.Errorf("failed to set FROM address: %s", err) + return + } + if err := message.To("valid-to@domain.tld"); err != nil { + t.Errorf("failed to set TO address: %s", err) + return + } + message.Subject("Test subject") + message.SetBodyString(TypeTextPlain, "Test body") + message.SetMessageIDWithValue("this.is.a.message.id") + messages = append(messages, message) + } + + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + + wg := sync.WaitGroup{} + for id, message := range messages { + wg.Add(1) + go func(curMsg *Msg, curID int) { + defer wg.Done() + if goroutineErr := client.Send(curMsg); err != nil { + t.Errorf("failed to send message with ID %d: %s", curID, goroutineErr) + } + }(message, id) + } + wg.Wait() + + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } +} + +func TestClient_AuthSCRAMSHAX(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + username := os.Getenv("TEST_USER_SCRAM") + password := os.Getenv("TEST_PASS_SCRAM") + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + +func TestClient_AuthLoginSuccess(t *testing.T) { + tests := []struct { + name string + featureSet string + }{ + {"default", "250-AUTH LOGIN\r\n250-X-DEFAULT-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, + {"mox server", "250-AUTH LOGIN\r\n250-X-MOX-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, + {"null byte", "250-AUTH LOGIN\r\n250-X-NULLBYTE-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, + {"bogus responses", "250-AUTH LOGIN\r\n250-X-BOGUS-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, + {"empty responses", "250-AUTH LOGIN\r\n250-X-EMPTY-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"}, + } + + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 40 + i + go func() { + if err := simpleSMTPServer(ctx, tt.featureSet, true, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + client, err := NewClient(TestServerAddr, + WithPort(serverPort), + WithTLSPortPolicy(NoTLS), + WithSMTPAuth(SMTPAuthLogin), + WithUsername("toni@tester.com"), + WithPassword("V3ryS3cr3t+")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + +func TestClient_AuthLoginFail(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 50 + featureSet := "250-AUTH LOGIN\r\n250-X-DEFAULT-LOGIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, true, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 300) + + client, err := NewClient(TestServerAddr, + WithPort(serverPort), + WithTLSPortPolicy(NoTLS), + WithSMTPAuth(SMTPAuthLogin), + WithUsername("toni@tester.com"), + WithPassword("InvalidPassword")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err == nil { + t.Error("expected to fail to dial to test server, but it succeeded") + } +} + +func TestClient_AuthLoginFail_noTLS(t *testing.T) { + if os.Getenv("TEST_SKIP_ONLINE") != "" { + t.Skipf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") + } + th := os.Getenv("TEST_HOST") + if th == "" { + t.Skipf("no host set. Skipping online tests") + } + tp := 587 + if tps := os.Getenv("TEST_PORT"); tps != "" { + tpi, err := strconv.Atoi(tps) + if err == nil { + tp = tpi + } + } + client, err := NewClient(th, WithPort(tp), WithSMTPAuth(SMTPAuthLogin), WithTLSPolicy(NoTLS)) + if err != nil { + t.Errorf("failed to create new client: %s", err) + } + u := os.Getenv("TEST_SMTPAUTH_USER") + if u != "" { + client.SetUsername(u) + } + p := os.Getenv("TEST_SMTPAUTH_PASS") + if p != "" { + client.SetPassword(p) + } + // We don't want to log authentication data in tests + client.SetDebugLog(false) + + if err = client.DialWithContext(context.Background()); err == nil { + t.Error("expected to fail to dial to test server, but it succeeded") + } + if !errors.Is(err, smtp.ErrUnencrypted) { + t.Errorf("expected error to be %s, but got %s", smtp.ErrUnencrypted, err) + } +} + +func TestClient_AuthSCRAMSHAX_fail(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername("invalid"), WithPassword("invalid")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err == nil { + t.Errorf("expected error but got nil") + } + }) + } +} + +func TestClient_AuthSCRAMSHAX_unsupported(t *testing.T) { + if os.Getenv("TEST_ALLOW_SEND") == "" { + t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") + } + + client, err := getTestConnection(true) + if err != nil { + t.Skipf("failed to create test client: %s. Skipping tests", err) + } + + tests := []struct { + name string + authtype SMTPAuthType + expErr error + }{ + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1, ErrSCRAMSHA1AuthNotSupported}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS, ErrSCRAMSHA1PLUSAuthNotSupported}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256, ErrSCRAMSHA256AuthNotSupported}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS, ErrSCRAMSHA256PLUSAuthNotSupported}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client.SetSMTPAuth(tt.authtype) + client.SetTLSPolicy(TLSMandatory) + if err = client.DialWithContext(context.Background()); err == nil { + t.Errorf("expected error but got nil") + } + if !errors.Is(err, tt.expErr) { + t.Errorf("expected error %s, but got %s", tt.expErr, err) + } + }) + } +} + +func TestClient_AuthSCRAMSHAXPLUS_tlsexporter(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + username := os.Getenv("TEST_USER_SCRAM") + password := os.Getenv("TEST_PASS_SCRAM") + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + +func TestClient_AuthSCRAMSHAXPLUS_tlsunique(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + username := os.Getenv("TEST_USER_SCRAM") + password := os.Getenv("TEST_PASS_SCRAM") + tlsConfig := &tls.Config{} + tlsConfig.MaxVersion = tls.VersionTLS12 + tlsConfig.ServerName = hostname + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithTLSConfig(tlsConfig), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + // getTestConnection takes environment variables to establish a connection to a real // SMTP server to test all functionality that requires a connection func getTestConnection(auth bool) (*Client, error) { @@ -1293,6 +2201,50 @@ func getTestConnection(auth bool) (*Client, error) { // We don't want to log authentication data in tests c.SetDebugLog(false) } + if err = c.DialWithContext(context.Background()); err != nil { + return c, fmt.Errorf("connection to test server failed: %w", err) + } + if err = c.Close(); err != nil { + return c, fmt.Errorf("disconnect from test server failed: %w", err) + } + return c, nil +} + +// getTestConnectionNoTestPort takes environment variables (except the port) to establish a +// connection to a real SMTP server to test all functionality that requires a connection +func getTestConnectionNoTestPort(auth bool) (*Client, error) { + if os.Getenv("TEST_SKIP_ONLINE") != "" { + return nil, fmt.Errorf("env variable TEST_SKIP_ONLINE is set. Skipping online tests") + } + th := os.Getenv("TEST_HOST") + if th == "" { + return nil, fmt.Errorf("no TEST_HOST set") + } + sv := false + if sve := os.Getenv("TEST_TLS_SKIP_VERIFY"); sve != "" { + sv = true + } + c, err := NewClient(th) + if err != nil { + return c, err + } + c.tlsconfig.InsecureSkipVerify = sv + if auth { + st := os.Getenv("TEST_SMTPAUTH_TYPE") + if st != "" { + c.SetSMTPAuth(SMTPAuthType(st)) + } + u := os.Getenv("TEST_SMTPAUTH_USER") + if u != "" { + c.SetUsername(u) + } + p := os.Getenv("TEST_SMTPAUTH_PASS") + if p != "" { + c.SetPassword(p) + } + // We don't want to log authentication data in tests + c.SetDebugLog(false) + } if err := c.DialWithContext(context.Background()); err != nil { return c, fmt.Errorf("connection to test server failed: %w", err) } @@ -1357,7 +2309,14 @@ func getTestConnectionWithDSN(auth bool) (*Client, error) { if th == "" { return nil, fmt.Errorf("no TEST_HOST set") } - c, err := NewClient(th, WithDSN()) + tp := 25 + if tps := os.Getenv("TEST_PORT"); tps != "" { + tpi, err := strconv.Atoi(tps) + if err == nil { + tp = tpi + } + } + c, err := NewClient(th, WithDSN(), WithPort(tp)) if err != nil { return c, err } @@ -1385,6 +2344,72 @@ func getTestConnectionWithDSN(auth bool) (*Client, error) { } func TestXOAuth2OK(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 30 + featureSet := "250-AUTH XOAUTH2\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 500) + + c, err := NewClient("127.0.0.1", + WithPort(serverPort), + WithTLSPortPolicy(TLSOpportunistic), + WithSMTPAuth(SMTPAuthXOAUTH2), + WithUsername("user"), + WithPassword("token")) + if err != nil { + t.Fatalf("unable to create new client: %v", err) + } + if err = c.DialWithContext(context.Background()); err != nil { + t.Fatalf("unexpected dial error: %v", err) + } + if err = c.Close(); err != nil { + t.Fatalf("disconnect from test server failed: %v", err) + } +} + +func TestXOAuth2Unsupported(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverPort := TestServerPortBase + 31 + featureSet := "250-AUTH LOGIN PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8" + go func() { + if err := simpleSMTPServer(ctx, featureSet, false, serverPort); err != nil { + t.Errorf("failed to start test server: %s", err) + return + } + }() + time.Sleep(time.Millisecond * 500) + + c, err := NewClient("127.0.0.1", + WithPort(serverPort), + WithTLSPolicy(TLSOpportunistic), + WithSMTPAuth(SMTPAuthXOAUTH2), + WithUsername("user"), + WithPassword("token")) + if err != nil { + t.Fatalf("unable to create new client: %v", err) + } + if err = c.DialWithContext(context.Background()); err == nil { + t.Fatal("expected dial error got nil") + } else { + if !errors.Is(err, ErrXOauth2AuthNotSupported) { + t.Fatalf("expected %v; got %v", ErrXOauth2AuthNotSupported, err) + } + } + if err = c.Close(); err != nil { + t.Fatalf("disconnect from test server failed: %v", err) + } +} + +func TestXOAuth2OK_faker(t *testing.T) { server := []string{ "220 Fake server ready ESMTP", "250-fake.server", @@ -1392,7 +2417,6 @@ func TestXOAuth2OK(t *testing.T) { "250 8BITMIME", "250 OK", "235 2.7.0 Accepted", - "250 OK", "221 OK", } var wrote strings.Builder @@ -1413,10 +2437,10 @@ func TestXOAuth2OK(t *testing.T) { if err != nil { t.Fatalf("unable to create new client: %v", err) } - if err := c.DialWithContext(context.Background()); err != nil { + if err = c.DialWithContext(context.Background()); err != nil { t.Fatalf("unexpected dial error: %v", err) } - if err := c.Close(); err != nil { + if err = c.Close(); err != nil { t.Fatalf("disconnect from test server failed: %v", err) } if !strings.Contains(wrote.String(), "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n") { @@ -1424,14 +2448,13 @@ func TestXOAuth2OK(t *testing.T) { } } -func TestXOAuth2Unsupported(t *testing.T) { +func TestXOAuth2Unsupported_faker(t *testing.T) { server := []string{ "220 Fake server ready ESMTP", "250-fake.server", "250-AUTH LOGIN PLAIN", "250 8BITMIME", "250 OK", - "250 OK", "221 OK", } var wrote strings.Builder @@ -1450,18 +2473,18 @@ func TestXOAuth2Unsupported(t *testing.T) { if err != nil { t.Fatalf("unable to create new client: %v", err) } - if err := c.DialWithContext(context.Background()); err == nil { + if err = c.DialWithContext(context.Background()); err == nil { t.Fatal("expected dial error got nil") } else { if !errors.Is(err, ErrXOauth2AuthNotSupported) { t.Fatalf("expected %v; got %v", ErrXOauth2AuthNotSupported, err) } } - if err := c.Close(); err != nil { + if err = c.Close(); err != nil { t.Fatalf("disconnect from test server failed: %v", err) } client := strings.Split(wrote.String(), "\r\n") - if len(client) != 5 { + if len(client) != 4 { t.Fatalf("unexpected number of client requests got %d; want 5", len(client)) } if !strings.HasPrefix(client[0], "EHLO") { @@ -1470,10 +2493,7 @@ func TestXOAuth2Unsupported(t *testing.T) { if client[1] != "NOOP" { t.Fatalf("expected NOOP, got %q", client[1]) } - if client[2] != "NOOP" { - t.Fatalf("expected NOOP, got %q", client[2]) - } - if client[3] != "QUIT" { + if client[2] != "QUIT" { t.Fatalf("expected QUIT, got %q", client[3]) } } @@ -1494,3 +2514,202 @@ func (f faker) RemoteAddr() net.Addr { return nil } func (f faker) SetDeadline(time.Time) error { return nil } func (f faker) SetReadDeadline(time.Time) error { return nil } func (f faker) SetWriteDeadline(time.Time) error { return nil } + +// simpleSMTPServer starts a simple TCP server that resonds to SMTP commands. +// The provided featureSet represents in what the server responds to EHLO command +// failReset controls if a RSET succeeds +func simpleSMTPServer(ctx context.Context, featureSet string, failReset bool, port int) error { + listener, err := net.Listen(TestServerProto, fmt.Sprintf("%s:%d", TestServerAddr, port)) + if err != nil { + return fmt.Errorf("unable to listen on %s://%s: %w", TestServerProto, TestServerAddr, err) + } + + defer func() { + if err := listener.Close(); err != nil { + fmt.Printf("unable to close listener: %s\n", err) + os.Exit(1) + } + }() + + for { + select { + case <-ctx.Done(): + return nil + default: + connection, err := listener.Accept() + var opErr *net.OpError + if err != nil { + if errors.As(err, &opErr) && opErr.Temporary() { + continue + } + return fmt.Errorf("unable to accept connection: %w", err) + } + handleTestServerConnection(connection, featureSet, failReset) + } + } +} + +func handleTestServerConnection(connection net.Conn, featureSet string, failReset bool) { + defer func() { + if err := connection.Close(); err != nil { + fmt.Printf("unable to close connection: %s\n", err) + } + }() + + reader := bufio.NewReader(connection) + writer := bufio.NewWriter(connection) + + writeLine := func(data string) error { + _, err := writer.WriteString(data + "\r\n") + if err != nil { + return fmt.Errorf("unable to write line: %w", err) + } + return writer.Flush() + } + writeOK := func() { + _ = writeLine("250 2.0.0 OK") + } + + if err := writeLine("220 go-mail test server ready ESMTP"); err != nil { + fmt.Printf("unable to write to client: %s\n", err) + return + } + + data, err := reader.ReadString('\n') + if err != nil { + return + } + if !strings.HasPrefix(data, "EHLO") && !strings.HasPrefix(data, "HELO") { + fmt.Printf("expected EHLO, got %q", data) + return + } + if err = writeLine("250-localhost.localdomain\r\n" + featureSet); err != nil { + return + } + + for { + data, err = reader.ReadString('\n') + if err != nil { + break + } + time.Sleep(time.Millisecond) + + var datastring string + data = strings.TrimSpace(data) + switch { + case strings.HasPrefix(data, "MAIL FROM:"): + from := strings.TrimPrefix(data, "MAIL FROM:") + from = strings.ReplaceAll(from, "BODY=8BITMIME", "") + from = strings.ReplaceAll(from, "SMTPUTF8", "") + from = strings.TrimSpace(from) + if !strings.EqualFold(from, "") { + _ = writeLine(fmt.Sprintf("503 5.1.2 Invalid from: %s", from)) + break + } + writeOK() + case strings.HasPrefix(data, "RCPT TO:"): + to := strings.TrimPrefix(data, "RCPT TO:") + to = strings.TrimSpace(to) + if !strings.EqualFold(to, "") { + _ = writeLine(fmt.Sprintf("500 5.1.2 Invalid to: %s", to)) + break + } + writeOK() + case strings.HasPrefix(data, "AUTH XOAUTH2"): + auth := strings.TrimPrefix(data, "AUTH XOAUTH2 ") + if !strings.EqualFold(auth, "dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=") { + _ = writeLine("535 5.7.8 Error: authentication failed") + break + } + _ = writeLine("235 2.7.0 Authentication successful") + case strings.HasPrefix(data, "AUTH PLAIN"): + auth := strings.TrimPrefix(data, "AUTH PLAIN ") + if !strings.EqualFold(auth, "AHRvbmlAdGVzdGVyLmNvbQBWM3J5UzNjcjN0Kw==") { + _ = writeLine("535 5.7.8 Error: authentication failed") + break + } + _ = writeLine("235 2.7.0 Authentication successful") + case strings.HasPrefix(data, "AUTH LOGIN"): + var username, password string + userResp := "VXNlcm5hbWU6" + passResp := "UGFzc3dvcmQ6" + if strings.Contains(featureSet, "250-X-MOX-LOGIN") { + userResp = "" + passResp = "UGFzc3dvcmQ=" + } + if strings.Contains(featureSet, "250-X-NULLBYTE-LOGIN") { + userResp = "VXNlciBuYW1lAA==" + passResp = "UGFzc3dvcmQA" + } + if strings.Contains(featureSet, "250-X-BOGUS-LOGIN") { + userResp = "Qm9ndXM=" + passResp = "Qm9ndXM=" + } + if strings.Contains(featureSet, "250-X-EMPTY-LOGIN") { + userResp = "" + passResp = "" + } + _ = writeLine("334 " + userResp) + + ddata, derr := reader.ReadString('\n') + if derr != nil { + fmt.Printf("failed to read username data from connection: %s\n", derr) + break + } + ddata = strings.TrimSpace(ddata) + username = ddata + _ = writeLine("334 " + passResp) + + ddata, derr = reader.ReadString('\n') + if derr != nil { + fmt.Printf("failed to read password data from connection: %s\n", derr) + break + } + ddata = strings.TrimSpace(ddata) + password = ddata + + if !strings.EqualFold(username, "dG9uaUB0ZXN0ZXIuY29t") || + !strings.EqualFold(password, "VjNyeVMzY3IzdCs=") { + _ = writeLine("535 5.7.8 Error: authentication failed") + break + } + _ = writeLine("235 2.7.0 Authentication successful") + case strings.EqualFold(data, "DATA"): + _ = writeLine("354 End data with .") + for { + ddata, derr := reader.ReadString('\n') + if derr != nil { + fmt.Printf("failed to read DATA data from connection: %s\n", derr) + break + } + ddata = strings.TrimSpace(ddata) + if strings.EqualFold(ddata, "DATA write should fail") { + _ = writeLine("500 5.0.0 Error during DATA transmission") + break + } + if ddata == "." { + if strings.Contains(datastring, "DATA close should fail") { + _ = writeLine("500 5.0.0 Error during DATA closing") + break + } + _ = writeLine("250 2.0.0 Ok: queued as 1234567890") + break + } + datastring += ddata + "\n" + } + case strings.EqualFold(data, "noop"), + strings.EqualFold(data, "vrfy"): + writeOK() + case strings.EqualFold(data, "rset"): + if failReset { + _ = writeLine("500 5.1.2 Error: reset failed") + break + } + writeOK() + case strings.EqualFold(data, "quit"): + _ = writeLine("221 2.0.0 Bye") + default: + _ = writeLine("500 5.5.2 Error: bad syntax") + } + } +} diff --git a/doc.go b/doc.go index 831a57c..c775015 100644 --- a/doc.go +++ b/doc.go @@ -2,8 +2,13 @@ // // SPDX-License-Identifier: MIT -// Package mail provides a simple and easy way to send mails with Go +// Package mail provides an easy to use interface for formating and sending mails. go-mail follows idiomatic Go style +// and best practice. It has a small dependency footprint by mainly relying on the Go Standard Library and the Go +// extended packages. It combines a lot of functionality from the standard library to give easy and convenient access +// to mail and SMTP related tasks. It works like a programatic email client and provides lots of methods and +// functionalities you would consider standard in a MUA. package mail -// VERSION is used in the default user agent string -const VERSION = "0.4.4" +// VERSION indicates the current version of the package. It is also attached to the default user +// agent string. +const VERSION = "0.5.0" diff --git a/eml.go b/eml.go index 7e705f6..35cd90d 100644 --- a/eml.go +++ b/eml.go @@ -18,14 +18,35 @@ import ( "strings" ) -// EMLToMsgFromString will parse a given EML string and returns a pre-filled Msg pointer +// EMLToMsgFromString parses a given EML string and returns a pre-filled Msg pointer. +// +// This function takes an EML formatted string, converts it into a bytes buffer, and then +// calls EMLToMsgFromReader to parse the buffer and create a Msg object. This provides a +// convenient way to convert EML strings directly into Msg objects. +// +// Parameters: +// - emlString: A string containing the EML formatted message. +// +// Returns: +// - A pointer to the Msg object populated with the parsed data, and an error if parsing +// fails. func EMLToMsgFromString(emlString string) (*Msg, error) { eb := bytes.NewBufferString(emlString) return EMLToMsgFromReader(eb) } -// EMLToMsgFromReader will parse a reader that holds EML content and returns a pre-filled -// Msg pointer +// EMLToMsgFromReader parses a reader that holds EML content and returns a pre-filled Msg pointer. +// +// This function reads EML content from the provided io.Reader and populates a Msg object +// with the parsed data. It initializes the Msg and extracts headers and body parts from +// the EML content. Any errors encountered during parsing are returned. +// +// Parameters: +// - reader: An io.Reader containing the EML formatted message. +// +// Returns: +// - A pointer to the Msg object populated with the parsed data, and an error if parsing +// fails. func EMLToMsgFromReader(reader io.Reader) (*Msg, error) { msg := &Msg{ addrHeader: make(map[AddrHeader][]*netmail.Address), @@ -46,8 +67,19 @@ func EMLToMsgFromReader(reader io.Reader) (*Msg, error) { return msg, nil } -// EMLToMsgFromFile will open and parse a .eml file at a provided file path and returns a -// pre-filled Msg pointer +// EMLToMsgFromFile opens and parses a .eml file at a provided file path and returns a +// pre-filled Msg pointer. +// +// This function attempts to read and parse an EML file located at the specified file path. +// It initializes a Msg object and populates it with the parsed headers and body. Any errors +// encountered during the file operations or parsing are returned. +// +// Parameters: +// - filePath: The path to the .eml file to be parsed. +// +// Returns: +// - A pointer to the Msg object populated with the parsed data, and an error if parsing +// fails. func EMLToMsgFromFile(filePath string) (*Msg, error) { msg := &Msg{ addrHeader: make(map[AddrHeader][]*netmail.Address), @@ -68,7 +100,19 @@ func EMLToMsgFromFile(filePath string) (*Msg, error) { return msg, nil } -// parseEML parses the EML's headers and body and inserts the parsed values into the Msg +// parseEML parses the EML's headers and body and inserts the parsed values into the Msg. +// +// This function extracts relevant header fields and body content from the parsed EML message +// and stores them in the provided Msg object. It handles various header types and body +// parts, ensuring that the Msg is correctly populated with all necessary information. +// +// Parameters: +// - parsedMsg: A pointer to the netmail.Message containing the parsed EML data. +// - bodybuf: A bytes.Buffer containing the body content of the EML message. +// - msg: A pointer to the Msg object to be populated with the parsed data. +// +// Returns: +// - An error if any issues occur during the parsing process; otherwise, returns nil. func parseEML(parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error { if err := parseEMLHeaders(&parsedMsg.Header, msg); err != nil { return fmt.Errorf("failed to parse EML headers: %w", err) @@ -79,7 +123,18 @@ func parseEML(parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error return nil } -// readEML opens an EML file and uses net/mail to parse the header and body +// readEML opens an EML file and uses net/mail to parse the header and body. +// +// This function opens the specified EML file for reading and utilizes the net/mail package +// to parse the message's headers and body. It returns the parsed message and a buffer +// containing the body content, along with any errors encountered during the process. +// +// Parameters: +// - filePath: The path to the EML file to be opened and parsed. +// +// Returns: +// - A pointer to the parsed netmail.Message, a bytes.Buffer containing the body, and an +// error if any issues occur during file operations or parsing. func readEML(filePath string) (*netmail.Message, *bytes.Buffer, error) { fileHandle, err := os.Open(filePath) if err != nil { @@ -91,7 +146,19 @@ func readEML(filePath string) (*netmail.Message, *bytes.Buffer, error) { return readEMLFromReader(fileHandle) } -// readEMLFromReader uses net/mail to parse the header and body from a given io.Reader +// readEMLFromReader uses net/mail to parse the header and body from a given io.Reader. +// +// This function reads the EML content from the provided io.Reader and uses the net/mail +// package to parse the message's headers and body. It returns the parsed netmail.Message +// along with a bytes.Buffer containing the body content. Any errors encountered during +// the parsing process are returned. +// +// Parameters: +// - reader: An io.Reader containing the EML formatted message. +// +// Returns: +// - A pointer to the parsed netmail.Message, a bytes.Buffer containing the body, and an +// error if any issues occur during parsing. func readEMLFromReader(reader io.Reader) (*netmail.Message, *bytes.Buffer, error) { parsedMsg, err := netmail.ReadMessage(reader) if err != nil { @@ -106,8 +173,18 @@ func readEMLFromReader(reader io.Reader) (*netmail.Message, *bytes.Buffer, error return parsedMsg, &buf, nil } -// parseEMLHeaders will check the EML headers for the most common headers and set the -// according settings in the Msg +// parseEMLHeaders parses the EML's headers and populates the Msg with relevant information. +// +// This function checks the EML headers for common headers and sets the corresponding fields +// in the Msg object. It extracts address headers, content types, and other relevant data +// for further processing. +// +// Parameters: +// - mailHeader: A pointer to the netmail.Header containing the EML headers. +// - msg: A pointer to the Msg object to be populated with parsed header information. +// +// Returns: +// - An error if parsing the headers fails; otherwise, returns nil. func parseEMLHeaders(mailHeader *netmail.Header, msg *Msg) error { commonHeaders := []Header{ HeaderContentType, HeaderImportance, HeaderInReplyTo, HeaderListUnsubscribe, @@ -175,7 +252,19 @@ func parseEMLHeaders(mailHeader *netmail.Header, msg *Msg) error { return nil } -// parseEMLBodyParts parses the body of a EML based on the different content types and encodings +// parseEMLBodyParts parses the body of an EML based on the different content types and encodings. +// +// This function examines the content type of the parsed EML message and processes the body +// parts accordingly. It handles both plain text and multipart types, ensuring that the +// Msg object is populated with the appropriate body content. +// +// Parameters: +// - parsedMsg: A pointer to the netmail.Message containing the parsed EML data. +// - bodybuf: A bytes.Buffer containing the body content of the EML message. +// - msg: A pointer to the Msg object to be populated with the parsed body content. +// +// Returns: +// - An error if any issues occur during the body parsing process; otherwise, returns nil. func parseEMLBodyParts(parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error { // Extract the transfer encoding of the body mediatype, params, err := mime.ParseMediaType(parsedMsg.Header.Get(HeaderContentType.String())) @@ -212,10 +301,24 @@ func parseEMLBodyParts(parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *M return nil } -// parseEMLBodyPlain parses the mail body of plain type mails +// parseEMLBodyPlain parses the mail body of plain type messages. +// +// This function handles the parsing of plain text messages based on their encoding. It +// identifies the content transfer encoding and decodes the body content accordingly, +// storing the result in the provided Msg object. +// +// Parameters: +// - mediatype: The media type of the message (e.g., text/plain). +// - parsedMsg: A pointer to the netmail.Message containing the parsed EML data. +// - bodybuf: A bytes.Buffer containing the body content of the EML message. +// - msg: A pointer to the Msg object to be populated with the parsed body content. +// +// Returns: +// - An error if any issues occur during the parsing of the plain body; otherwise, returns nil. func parseEMLBodyPlain(mediatype string, parsedMsg *netmail.Message, bodybuf *bytes.Buffer, msg *Msg) error { contentTransferEnc := parsedMsg.Header.Get(HeaderContentTransferEnc.String()) - // According to RFC2045, if no Content-Transfer-Encoding is set, we can imply 7bit US-ASCII encoding + // If no Content-Transfer-Encoding is set, we can imply 7bit US-ASCII encoding + // https://datatracker.ietf.org/doc/html/rfc2045#section-6.1 if contentTransferEnc == "" || strings.EqualFold(contentTransferEnc, EncodingUSASCII.String()) { msg.SetEncoding(EncodingUSASCII) msg.SetBodyString(ContentType(mediatype), bodybuf.String()) @@ -249,7 +352,20 @@ func parseEMLBodyPlain(mediatype string, parsedMsg *netmail.Message, bodybuf *by return fmt.Errorf("unsupported Content-Transfer-Encoding") } -// parseEMLMultipart parses a multipart body part of a EML +// parseEMLMultipart parses a multipart body part of an EML message. +// +// This function handles the parsing of multipart messages, extracting the individual parts +// and determining their content types. It processes each part according to its content type +// and ensures that all relevant data is stored in the Msg object. +// +// Parameters: +// - params: A map containing the parameters from the multipart content type. +// - bodybuf: A bytes.Buffer containing the body content of the EML message. +// - msg: A pointer to the Msg object to be populated with the parsed body parts. +// +// Returns: +// - An error if any issues occur during the parsing of the multipart body; otherwise, +// returns nil. func parseEMLMultipart(params map[string]string, bodybuf *bytes.Buffer, msg *Msg) error { boundary, ok := params["boundary"] if !ok { @@ -349,7 +465,15 @@ ReadNextPart: return nil } -// parseEMLEncoding parses and determines the encoding of the message +// parseEMLEncoding parses and determines the encoding of the message. +// +// This function extracts the content transfer encoding from the EML headers and sets the +// corresponding encoding in the Msg object. It ensures that the correct encoding is used +// for further processing of the message content. +// +// Parameters: +// - mailHeader: A pointer to the netmail.Header containing the EML headers. +// - msg: A pointer to the Msg object to be updated with the encoding information. func parseEMLEncoding(mailHeader *netmail.Header, msg *Msg) { if value := mailHeader.Get(HeaderContentTransferEnc.String()); value != "" { switch { @@ -363,7 +487,15 @@ func parseEMLEncoding(mailHeader *netmail.Header, msg *Msg) { } } -// parseEMLContentTypeCharset parses and determines the charset and content type of the message +// parseEMLContentTypeCharset parses and determines the charset and content type of the message. +// +// This function extracts the content type and charset from the EML headers, setting them +// appropriately in the Msg object. It ensures that the Msg object is configured with the +// correct content type for further processing. +// +// Parameters: +// - mailHeader: A pointer to the netmail.Header containing the EML headers. +// - msg: A pointer to the Msg object to be updated with content type and charset information. func parseEMLContentTypeCharset(mailHeader *netmail.Header, msg *Msg) { if value := mailHeader.Get(HeaderContentType.String()); value != "" { contentType, optional := parseMultiPartHeader(value) @@ -377,7 +509,18 @@ func parseEMLContentTypeCharset(mailHeader *netmail.Header, msg *Msg) { } } -// handleEMLMultiPartBase64Encoding sets the content body of a base64 encoded Part +// handleEMLMultiPartBase64Encoding sets the content body of a base64 encoded Part. +// +// This function decodes the base64 encoded content of a multipart part and stores the +// resulting content in the provided Part object. It handles any errors that occur during +// the decoding process. +// +// Parameters: +// - multiPartData: A byte slice containing the base64 encoded data. +// - part: A pointer to the Part object where the decoded content will be stored. +// +// Returns: +// - An error if the base64 decoding fails; otherwise, returns nil. func handleEMLMultiPartBase64Encoding(multiPartData []byte, part *Part) error { part.SetEncoding(EncodingB64) content, err := base64.StdEncoding.DecodeString(string(multiPartData)) @@ -388,8 +531,17 @@ func handleEMLMultiPartBase64Encoding(multiPartData []byte, part *Part) error { return nil } -// parseMultiPartHeader parses a multipart header and returns the value and optional parts as -// separate map +// parseMultiPartHeader parses a multipart header and returns the value and optional parts as a map. +// +// This function splits a multipart header into its main value and any optional parameters, +// returning them separately. It helps in processing multipart messages by extracting +// relevant information from headers. +// +// Parameters: +// - multiPartHeader: A string representing the multipart header to be parsed. +// +// Returns: +// - The main header value as a string and a map of optional parameters. func parseMultiPartHeader(multiPartHeader string) (header string, optional map[string]string) { optional = make(map[string]string) headerSplit := strings.SplitN(multiPartHeader, ";", 2) @@ -404,7 +556,20 @@ func parseMultiPartHeader(multiPartHeader string) (header string, optional map[s return } -// parseEMLAttachmentEmbed parses a multipart that is an attachment or embed +// parseEMLAttachmentEmbed parses a multipart that is an attachment or embed. +// +// This function handles the parsing of multipart sections that are marked as attachments or +// embedded content. It processes the content disposition and sets the appropriate fields in +// the Msg object based on the parsed data. +// +// Parameters: +// - contentDisposition: A slice of strings containing the content disposition header. +// - multiPart: A pointer to the multipart.Part to be parsed. +// - msg: A pointer to the Msg object to be populated with the attachment or embed data. +// +// Returns: +// - An error if any issues occur during the parsing of attachments or embeds; otherwise, +// returns nil. func parseEMLAttachmentEmbed(contentDisposition []string, multiPart *multipart.Part, msg *Msg) error { cdType, optional := parseMultiPartHeader(contentDisposition[0]) filename := "generic.attachment" diff --git a/encoding.go b/encoding.go index 4be2e38..aa9cceb 100644 --- a/encoding.go +++ b/encoding.go @@ -4,173 +4,221 @@ package mail -// Charset represents a character set for the encoding +// Charset is a type wrapper for a string representing different character encodings. type Charset string -// ContentType represents a content type for the Msg +// ContentType is a type wrapper for a string and represents the MIME type of the content being handled. type ContentType string -// Encoding represents a MIME encoding scheme like quoted-printable or Base64. +// Encoding is a type wrapper for a string and represents the type of encoding used for email messages +// and/or parts. type Encoding string -// MIMEVersion represents the MIME version for the mail +// MIMEVersion is a type wrapper for a string nad represents the MIME version used in email messages. type MIMEVersion string -// MIMEType represents the MIME type for the mail +// MIMEType is a type wrapper for a string and represents the MIME type for the Msg content or parts. type MIMEType string -// List of supported encodings const ( // EncodingB64 represents the Base64 encoding as specified in RFC 2045. + // + // https://datatracker.ietf.org/doc/html/rfc2045#section-6.8 EncodingB64 Encoding = "base64" // EncodingQP represents the "quoted-printable" encoding as specified in RFC 2045. + // + // https://datatracker.ietf.org/doc/html/rfc2045#section-6.7 EncodingQP Encoding = "quoted-printable" // EncodingUSASCII represents encoding with only US-ASCII characters (aka 7Bit) + // + // https://datatracker.ietf.org/doc/html/rfc2045#section-2.7 EncodingUSASCII Encoding = "7bit" - // NoEncoding avoids any character encoding (except of the mail headers) + // NoEncoding represents 8-bit encoding for email messages as specified in RFC 6152. + // + // https://datatracker.ietf.org/doc/html/rfc2045#section-2.8 + // + // https://datatracker.ietf.org/doc/html/rfc6152 NoEncoding Encoding = "8bit" ) -// List of common charsets const ( - // CharsetUTF7 represents the "UTF-7" charset + // CharsetUTF7 represents the "UTF-7" charset. CharsetUTF7 Charset = "UTF-7" - // CharsetUTF8 represents the "UTF-8" charset + // CharsetUTF8 represents the "UTF-8" charset. CharsetUTF8 Charset = "UTF-8" - // CharsetASCII represents the "US-ASCII" charset + // CharsetASCII represents the "US-ASCII" charset. CharsetASCII Charset = "US-ASCII" - // CharsetISO88591 represents the "ISO-8859-1" charset + // CharsetISO88591 represents the "ISO-8859-1" charset. CharsetISO88591 Charset = "ISO-8859-1" - // CharsetISO88592 represents the "ISO-8859-2" charset + // CharsetISO88592 represents the "ISO-8859-2" charset. CharsetISO88592 Charset = "ISO-8859-2" - // CharsetISO88593 represents the "ISO-8859-3" charset + // CharsetISO88593 represents the "ISO-8859-3" charset. CharsetISO88593 Charset = "ISO-8859-3" - // CharsetISO88594 represents the "ISO-8859-4" charset + // CharsetISO88594 represents the "ISO-8859-4" charset. CharsetISO88594 Charset = "ISO-8859-4" - // CharsetISO88595 represents the "ISO-8859-5" charset + // CharsetISO88595 represents the "ISO-8859-5" charset. CharsetISO88595 Charset = "ISO-8859-5" - // CharsetISO88596 represents the "ISO-8859-6" charset + // CharsetISO88596 represents the "ISO-8859-6" charset. CharsetISO88596 Charset = "ISO-8859-6" - // CharsetISO88597 represents the "ISO-8859-7" charset + // CharsetISO88597 represents the "ISO-8859-7" charset. CharsetISO88597 Charset = "ISO-8859-7" - // CharsetISO88599 represents the "ISO-8859-9" charset + // CharsetISO88599 represents the "ISO-8859-9" charset. CharsetISO88599 Charset = "ISO-8859-9" - // CharsetISO885913 represents the "ISO-8859-13" charset + // CharsetISO885913 represents the "ISO-8859-13" charset. CharsetISO885913 Charset = "ISO-8859-13" - // CharsetISO885914 represents the "ISO-8859-14" charset + // CharsetISO885914 represents the "ISO-8859-14" charset. CharsetISO885914 Charset = "ISO-8859-14" - // CharsetISO885915 represents the "ISO-8859-15" charset + // CharsetISO885915 represents the "ISO-8859-15" charset. CharsetISO885915 Charset = "ISO-8859-15" - // CharsetISO885916 represents the "ISO-8859-16" charset + // CharsetISO885916 represents the "ISO-8859-16" charset. CharsetISO885916 Charset = "ISO-8859-16" - // CharsetISO2022JP represents the "ISO-2022-JP" charset + // CharsetISO2022JP represents the "ISO-2022-JP" charset. CharsetISO2022JP Charset = "ISO-2022-JP" - // CharsetISO2022KR represents the "ISO-2022-KR" charset + // CharsetISO2022KR represents the "ISO-2022-KR" charset. CharsetISO2022KR Charset = "ISO-2022-KR" - // CharsetWindows1250 represents the "windows-1250" charset + // CharsetWindows1250 represents the "windows-1250" charset. CharsetWindows1250 Charset = "windows-1250" - // CharsetWindows1251 represents the "windows-1251" charset + // CharsetWindows1251 represents the "windows-1251" charset. CharsetWindows1251 Charset = "windows-1251" - // CharsetWindows1252 represents the "windows-1252" charset + // CharsetWindows1252 represents the "windows-1252" charset. CharsetWindows1252 Charset = "windows-1252" - // CharsetWindows1255 represents the "windows-1255" charset + // CharsetWindows1255 represents the "windows-1255" charset. CharsetWindows1255 Charset = "windows-1255" - // CharsetWindows1256 represents the "windows-1256" charset + // CharsetWindows1256 represents the "windows-1256" charset. CharsetWindows1256 Charset = "windows-1256" - // CharsetKOI8R represents the "KOI8-R" charset + // CharsetKOI8R represents the "KOI8-R" charset. CharsetKOI8R Charset = "KOI8-R" - // CharsetKOI8U represents the "KOI8-U" charset + // CharsetKOI8U represents the "KOI8-U" charset. CharsetKOI8U Charset = "KOI8-U" - // CharsetBig5 represents the "Big5" charset + // CharsetBig5 represents the "Big5" charset. CharsetBig5 Charset = "Big5" - // CharsetGB18030 represents the "GB18030" charset + // CharsetGB18030 represents the "GB18030" charset. CharsetGB18030 Charset = "GB18030" - // CharsetGB2312 represents the "GB2312" charset + // CharsetGB2312 represents the "GB2312" charset. CharsetGB2312 Charset = "GB2312" - // CharsetTIS620 represents the "TIS-620" charset + // CharsetTIS620 represents the "TIS-620" charset. CharsetTIS620 Charset = "TIS-620" - // CharsetEUCKR represents the "EUC-KR" charset + // CharsetEUCKR represents the "EUC-KR" charset. CharsetEUCKR Charset = "EUC-KR" - // CharsetShiftJIS represents the "Shift_JIS" charset + // CharsetShiftJIS represents the "Shift_JIS" charset. CharsetShiftJIS Charset = "Shift_JIS" - // CharsetUnknown represents the "Unknown" charset + // CharsetUnknown represents the "Unknown" charset. CharsetUnknown Charset = "Unknown" - // CharsetGBK represents the "GBK" charset + // CharsetGBK represents the "GBK" charset. CharsetGBK Charset = "GBK" ) -// List of MIME versions -const ( - // MIME10 is the MIME Version 1.0 - MIME10 MIMEVersion = "1.0" -) +// MIME10 represents the MIME version "1.0" used in email messages. +const MIME10 MIMEVersion = "1.0" -// List of common content types const ( - TypeAppOctetStream ContentType = "application/octet-stream" + // TypeAppOctetStream represents the MIME type for arbitrary binary data. + TypeAppOctetStream ContentType = "application/octet-stream" + + // TypeMultipartAlternative represents the MIME type for a message body that can contain multiple alternative + // formats. TypeMultipartAlternative ContentType = "multipart/alternative" - TypeMultipartMixed ContentType = "multipart/mixed" - TypeMultipartRelated ContentType = "multipart/related" - TypePGPSignature ContentType = "application/pgp-signature" - TypePGPEncrypted ContentType = "application/pgp-encrypted" - TypeTextHTML ContentType = "text/html" - TypeTextPlain ContentType = "text/plain" - typeSMimeSigned ContentType = `application/pkcs7-signature; name="smime.p7s"` + + // TypeMultipartMixed represents the MIME type for a multipart message containing different parts. + TypeMultipartMixed ContentType = "multipart/mixed" + + // TypeMultipartRelated represents the MIME type for a multipart message where each part is a related file + // or resource. + TypeMultipartRelated ContentType = "multipart/related" + + // TypePGPSignature represents the MIME type for PGP signed messages. + TypePGPSignature ContentType = "application/pgp-signature" + + // TypePGPEncrypted represents the MIME type for PGP encrypted messages. + TypePGPEncrypted ContentType = "application/pgp-encrypted" + + // TypeTextHTML represents the MIME type for HTML text content. + TypeTextHTML ContentType = "text/html" + + // TypeTextPlain represents the MIME type for plain text content. + TypeTextPlain ContentType = "text/plain" + + // typeSMimeSigned represents the MIME type for S/MIME singed messages. + typeSMimeSigned ContentType = `application/pkcs7-signature; name="smime.p7s"` ) -// List of MIMETypes const ( + // MIMEAlternative MIMEType represents a MIME multipart/alternative type, used for emails with multiple versions. MIMEAlternative MIMEType = "alternative" + // MIMEMixed MIMEType represents a MIME multipart/mixed type used for emails containing different types of content. MIMEMixed MIMEType = "mixed" + // MIMERelated MIMEType represents a MIME multipart/related type, used for emails with related content entities. MIMERelated MIMEType = "related" + // MIMESMime MIMEType represents a MIME multipart/signed type, used for siging emails with S/MIME. MIMESMime MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha-256` ) -// String is a standard method to convert an Charset into a printable format +// String satisfies the fmt.Stringer interface for the Charset type. +// It converts a Charset into a printable format. +// +// This method returns the string representation of the Charset, allowing it to be easily +// printed or logged. +// +// Returns: +// - A string representation of the Charset. func (c Charset) String() string { return string(c) } -// String is a standard method to convert an ContentType into a printable format +// String satisfies the fmt.Stringer interface for the ContentType type. +// It converts a ContentType into a printable format. +// +// This method returns the string representation of the ContentType, enabling its use +// in formatted output such as logging or displaying information to the user. +// +// Returns: +// - A string representation of the ContentType. func (c ContentType) String() string { return string(c) } -// String is a standard method to convert an Encoding into a printable format +// String satisfies the fmt.Stringer interface for the Encoding type. +// It converts an Encoding into a printable format. +// +// This method returns the string representation of the Encoding, which can be used +// for displaying or logging purposes. +// +// Returns: +// - A string representation of the Encoding. func (e Encoding) String() string { return string(e) } diff --git a/file.go b/file.go index 45e142a..8866d97 100644 --- a/file.go +++ b/file.go @@ -9,10 +9,15 @@ import ( "net/textproto" ) -// FileOption returns a function that can be used for grouping File options +// FileOption is a function type used to modify properties of a File type FileOption func(*File) -// File is an attachment or embedded file of the Msg +// File represents a file with properties such as content type, description, encoding, headers, name, and +// a writer function. +// +// This struct can represent either an attachment or an embedded file in a Msg, and it stores relevant +// metadata such as content type and encoding, as well as a function to write the file's content to an +// io.Writer. type File struct { ContentType ContentType Desc string @@ -22,32 +27,68 @@ type File struct { Writer func(w io.Writer) (int64, error) } -// WithFileContentID sets the Content-ID header for the File +// WithFileContentID sets the "Content-ID" header in the File's MIME headers to the specified ID. +// +// This function updates the File's MIME headers by setting the "Content-ID" to the provided string value, +// allowing the file to be referenced by this ID within the MIME structure. +// +// Parameters: +// - id: A string representing the content ID to be set in the "Content-ID" header. +// +// Returns: +// - A FileOption function that updates the File's "Content-ID" header. func WithFileContentID(id string) FileOption { return func(f *File) { f.Header.Set(HeaderContentID.String(), id) } } -// WithFileName sets the filename of the File +// WithFileName sets the name of a File to the provided value. +// +// This function assigns the specified name to the File, updating its Name field. +// +// Parameters: +// - name: A string representing the name to be assigned to the File. +// +// Returns: +// - A FileOption function that sets the File's name. func WithFileName(name string) FileOption { return func(f *File) { f.Name = name } } -// WithFileDescription sets an optional file description of the File that will be -// added as Content-Description part +// WithFileDescription sets an optional description for the File, which is used in the Content-Description +// header of the MIME output. +// +// This function updates the File's description, allowing an additional text description to be added to +// the MIME headers for the file. +// +// Parameters: +// - description: A string representing the description to be set in the Content-Description header. +// +// Returns: +// - A FileOption function that sets the File's description. func WithFileDescription(description string) FileOption { return func(f *File) { f.Desc = description } } -// WithFileEncoding sets the encoding of the File. By default we should always use -// Base64 encoding but there might be exceptions, where this might come handy. -// Please note that quoted-printable should never be used for attachments/embeds. If this -// is provided as argument, the function will automatically override back to Base64 +// WithFileEncoding sets the encoding type for a File. +// +// This function allows the specification of an encoding type for the file, typically used for attachments +// or embedded files. By default, Base64 encoding should be used, but this function can override the +// default if needed. +// +// Note: Quoted-printable encoding (EncodingQP) must never be used for attachments or embeds. If EncodingQP +// is passed to this function, it will be ignored and the encoding will remain unchanged. +// +// Parameters: +// - encoding: The Encoding type to be assigned to the File, unless it's EncodingQP. +// +// Returns: +// - A FileOption function that sets the File's encoding. func WithFileEncoding(encoding Encoding) FileOption { return func(f *File) { if encoding == EncodingQP { @@ -58,23 +99,45 @@ func WithFileEncoding(encoding Encoding) FileOption { } // WithFileContentType sets the content type of the File. -// By default go-mail will try to guess the file type and its corresponding -// content type and fall back to application/octet-stream if the file type -// could not be guessed. In some cases, however, it might be needed to force -// this to a specific type. For such situations this override method can -// be used +// +// By default, the content type is guessed based on the file type, and if no matching type is identified, +// the default "application/octet-stream" is used. This FileOption allows overriding the guessed content +// type with a specific one if required. +// +// Parameters: +// - contentType: The ContentType to be assigned to the File. +// +// Returns: +// - A FileOption function that sets the File's content type. func WithFileContentType(contentType ContentType) FileOption { return func(f *File) { f.ContentType = contentType } } -// setHeader sets header fields to a File +// setHeader sets the value of a specified MIME header field for the File. +// +// This method updates the MIME headers of the File by assigning the provided value to the specified +// header field. +// +// Parameters: +// - header: The Header field to be updated. +// - value: A string representing the value to be set for the given header. func (f *File) setHeader(header Header, value string) { f.Header.Set(string(header), value) } -// getHeader return header fields of a File +// getHeader retrieves the value of the specified MIME header field. +// +// This method returns the value of the given header and a boolean indicating whether the header was found +// in the File's MIME headers. +// +// Parameters: +// - header: The Header field whose value is to be retrieved. +// +// Returns: +// - A string containing the value of the header. +// - A boolean indicating whether the header was present (true) or not (false). func (f *File) getHeader(header Header) (string, bool) { v := f.Header.Get(string(header)) return v, v != "" diff --git a/go.mod b/go.mod index a59f78a..24f14bb 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,8 @@ module github.com/wneessen/go-mail go 1.16 -require go.mozilla.org/pkcs7 v0.9.0 +require ( + go.mozilla.org/pkcs7 v0.9.0 + golang.org/x/crypto v0.28.0 + golang.org/x/text v0.19.0 +) diff --git a/go.sum b/go.sum index 4ca3691..f917906 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,68 @@ +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= \ No newline at end of file diff --git a/go.sum.license b/go.sum.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/go.sum.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/header.go b/header.go index 9191b7e..217823a 100644 --- a/header.go +++ b/header.go @@ -4,129 +4,146 @@ package mail -// Header represents a generic mail header field name +// Header is a type wrapper for a string and represents email header fields in a Msg. type Header string -// AddrHeader represents a address related mail Header field name +// AddrHeader is a type wrapper for a string and represents email address headers fields in a Msg. type AddrHeader string -// Importance represents a Importance/Priority value string +// Importance is a type wrapper for an int and represents the level of importance or priority for a Msg. type Importance int -// List of common generic header field names const ( - // HeaderContentDescription is the "Content-Description" header + // HeaderContentDescription is the "Content-Description" header. HeaderContentDescription Header = "Content-Description" - // HeaderContentDisposition is the "Content-Disposition" header + // HeaderContentDisposition is the "Content-Disposition" header. HeaderContentDisposition Header = "Content-Disposition" - // HeaderContentID is the "Content-ID" header + // HeaderContentID is the "Content-ID" header. HeaderContentID Header = "Content-ID" - // HeaderContentLang is the "Content-Language" header + // HeaderContentLang is the "Content-Language" header. HeaderContentLang Header = "Content-Language" - // HeaderContentLocation is the "Content-Location" header (RFC 2110) + // HeaderContentLocation is the "Content-Location" header (RFC 2110). + // https://datatracker.ietf.org/doc/html/rfc2110#section-4.3 HeaderContentLocation Header = "Content-Location" - // HeaderContentTransferEnc is the "Content-Transfer-Encoding" header + // HeaderContentTransferEnc is the "Content-Transfer-Encoding" header. HeaderContentTransferEnc Header = "Content-Transfer-Encoding" - // HeaderContentType is the "Content-Type" header + // HeaderContentType is the "Content-Type" header. HeaderContentType Header = "Content-Type" - // HeaderDate represents the "Date" field - // See: https://www.rfc-editor.org/rfc/rfc822#section-5.1 + // HeaderDate represents the "Date" field. + // https://datatracker.ietf.org/doc/html/rfc822#section-5.1 HeaderDate Header = "Date" - // HeaderDispositionNotificationTo is the MDN header as described in RFC8098 - // See: https://www.rfc-editor.org/rfc/rfc8098.html#section-2.1 + // HeaderDispositionNotificationTo is the MDN header as described in RFC 8098. + // https://datatracker.ietf.org/doc/html/rfc8098#section-2.1 HeaderDispositionNotificationTo Header = "Disposition-Notification-To" - // HeaderImportance represents the "Importance" field + // HeaderImportance represents the "Importance" field. HeaderImportance Header = "Importance" - // HeaderInReplyTo represents the "In-Reply-To" field + // HeaderInReplyTo represents the "In-Reply-To" field. HeaderInReplyTo Header = "In-Reply-To" - // HeaderListUnsubscribe is the "List-Unsubscribe" header field + // HeaderListUnsubscribe is the "List-Unsubscribe" header field. HeaderListUnsubscribe Header = "List-Unsubscribe" - // HeaderListUnsubscribePost is the "List-Unsubscribe-Post" header field + // HeaderListUnsubscribePost is the "List-Unsubscribe-Post" header field. HeaderListUnsubscribePost Header = "List-Unsubscribe-Post" - // HeaderMessageID represents the "Message-ID" field for message identification - // See: https://www.rfc-editor.org/rfc/rfc1036#section-2.1.5 + // HeaderMessageID represents the "Message-ID" field for message identification. + // https://datatracker.ietf.org/doc/html/rfc1036#section-2.1.5 HeaderMessageID Header = "Message-ID" - // HeaderMIMEVersion represents the "MIME-Version" field as per RFC 2045 - // See: https://datatracker.ietf.org/doc/html/rfc2045#section-4 + // HeaderMIMEVersion represents the "MIME-Version" field as per RFC 2045. + // https://datatracker.ietf.org/doc/html/rfc2045#section-4 HeaderMIMEVersion Header = "MIME-Version" - // HeaderOrganization is the "Organization" header field + // HeaderOrganization is the "Organization" header field. HeaderOrganization Header = "Organization" - // HeaderPrecedence is the "Precedence" header field + // HeaderPrecedence is the "Precedence" header field. HeaderPrecedence Header = "Precedence" - // HeaderPriority represents the "Priority" field + // HeaderPriority represents the "Priority" field. HeaderPriority Header = "Priority" - // HeaderReferences is the "References" header field + // HeaderReferences is the "References" header field. HeaderReferences Header = "References" - // HeaderReplyTo is the "Reply-To" header field + // HeaderReplyTo is the "Reply-To" header field. HeaderReplyTo Header = "Reply-To" - // HeaderSubject is the "Subject" header field + // HeaderSubject is the "Subject" header field. HeaderSubject Header = "Subject" - // HeaderUserAgent is the "User-Agent" header field + // HeaderUserAgent is the "User-Agent" header field. HeaderUserAgent Header = "User-Agent" - // HeaderXAutoResponseSuppress is the "X-Auto-Response-Suppress" header field + // HeaderXAutoResponseSuppress is the "X-Auto-Response-Suppress" header field. HeaderXAutoResponseSuppress Header = "X-Auto-Response-Suppress" - // HeaderXMailer is the "X-Mailer" header field + // HeaderXMailer is the "X-Mailer" header field. HeaderXMailer Header = "X-Mailer" - // HeaderXMSMailPriority is the "X-MSMail-Priority" header field + // HeaderXMSMailPriority is the "X-MSMail-Priority" header field. HeaderXMSMailPriority Header = "X-MSMail-Priority" - // HeaderXPriority is the "X-Priority" header field + // HeaderXPriority is the "X-Priority" header field. HeaderXPriority Header = "X-Priority" ) -// List of common address header field names const ( - // HeaderBcc is the "Blind Carbon Copy" header field + // HeaderBcc is the "Blind Carbon Copy" header field. HeaderBcc AddrHeader = "Bcc" - // HeaderCc is the "Carbon Copy" header field + // HeaderCc is the "Carbon Copy" header field. HeaderCc AddrHeader = "Cc" - // HeaderEnvelopeFrom is the envelope FROM header field - // It's not included in the mail body but only used by the Client for the envelope + // HeaderEnvelopeFrom is the envelope FROM header field. + // + // It is generally not included in the mail body but only used by the Client for the communication with the + // SMTP server. If the Msg has no "FROM" address set in the mail body, the msgWriter will try to use the + // envelope from address, if this has been set for the Msg. HeaderEnvelopeFrom AddrHeader = "EnvelopeFrom" - // HeaderFrom is the "From" header field + // HeaderFrom is the "From" header field. HeaderFrom AddrHeader = "From" - // HeaderTo is the "Receipient" header field + // HeaderTo is the "Receipient" header field. HeaderTo AddrHeader = "To" ) -// List of Importance values const ( + // ImportanceLow indicates a low level of importance or priority in a Msg. ImportanceLow Importance = iota + + // ImportanceNormal indicates a standard level of importance or priority for a Msg. ImportanceNormal + + // ImportanceHigh indicates a high level of importance or priority in a Msg. ImportanceHigh + + // ImportanceNonUrgent indicates a non-urgent level of importance or priority in a Msg. ImportanceNonUrgent + + // ImportanceUrgent indicates an urgent level of importance or priority in a Msg. ImportanceUrgent ) -// NumString returns the importance number string based on the Importance +// NumString returns a numerical string representation of the Importance level. +// +// This method maps ImportanceHigh and ImportanceUrgent to "1", while ImportanceNonUrgent and ImportanceLow +// are mapped to "0". Other values return an empty string. +// +// Returns: +// - A string representing the numerical value of the Importance level ("1" or "0"), or an empty string +// if the Importance level is unrecognized. func (i Importance) NumString() string { switch i { case ImportanceNonUrgent: @@ -142,7 +159,14 @@ func (i Importance) NumString() string { } } -// XPrioString returns the X-Priority number string based on the Importance +// XPrioString returns the X-Priority string representation of the Importance level. +// +// This method maps ImportanceHigh and ImportanceUrgent to "1", while ImportanceNonUrgent and ImportanceLow +// are mapped to "5". Other values return an empty string. +// +// Returns: +// - A string representing the X-Priority value of the Importance level ("1" or "5"), or an empty string +// if the Importance level is unrecognized. func (i Importance) XPrioString() string { switch i { case ImportanceNonUrgent: @@ -158,7 +182,14 @@ func (i Importance) XPrioString() string { } } -// String returns the importance string based on the Importance +// String satisfies the fmt.Stringer interface for the Importance type and returns the string +// representation of the Importance level. +// +// This method provides a human-readable string for each Importance level. +// +// Returns: +// - A string representing the Importance level ("non-urgent", "low", "high", or "urgent"), or an empty +// string if the Importance level is unrecognized. func (i Importance) String() string { switch i { case ImportanceNonUrgent: @@ -174,12 +205,20 @@ func (i Importance) String() string { } } -// String returns the header string based on the given Header +// String satisfies the fmt.Stringer interface for the Header type and returns the string +// representation of the Header. +// +// Returns: +// - A string representing the Header. func (h Header) String() string { return string(h) } -// String returns the address header string based on the given AddrHeader +// String satisfies the fmt.Stringer interface for the AddrHeader type and returns the string +// representation of the AddrHeader. +// +// Returns: +// - A string representing the AddrHeader. func (a AddrHeader) String() string { return string(a) } diff --git a/msg.go b/msg.go index c0327a9..455ba3b 100644 --- a/msg.go +++ b/msg.go @@ -28,111 +28,150 @@ var ( // ErrNoFromAddress should be used when a FROM address is requested but not set ErrNoFromAddress = errors.New("no FROM address set") - // ErrNoRcptAddresses should be used when the list of RCPTs is empty + // ErrNoRcptAddresses indicates that no recipient addresses have been set. ErrNoRcptAddresses = errors.New("no recipient addresses set") ) const ( - // errTplExecuteFailed is issued when the template execution was not successful + // errTplExecuteFailed indicates that the execution of a template has failed, including the underlying error. errTplExecuteFailed = "failed to execute template: %w" - // errTplPointerNil is issued when a template pointer is expected but it is nil + // errTplPointerNil indicates that a template pointer is nil, which prevents further template execution or + // processing. errTplPointerNil = "template pointer is nil" - // errParseMailAddr is used when a mail address could not be validated + // errParseMailAddr indicates that parsing of a mail address has failed, including the problematic address + // and error. errParseMailAddr = "failed to parse mail address %q: %w" ) const ( - // NoPGP indicates that a message should not be treated as PGP encrypted - // or signed and is the default value for a message + // NoPGP indicates that a message should not be treated as PGP encrypted or signed and is the default value + // for a message NoPGP PGPType = iota - // PGPEncrypt indicates that a message should be treated as PGP encrypted - // This works closely together with the corresponding go-mail-middleware + + // PGPEncrypt indicates that a message should be treated as PGP encrypted. This works closely together with + // the corresponding go-mail-middleware. PGPEncrypt - // PGPSignature indicates that a message should be treated as PGP signed - // This works closely together with the corresponding go-mail-middleware + + // PGPSignature indicates that a message should be treated as PGP signed. This works closely together with + // the corresponding go-mail-middleware. PGPSignature ) -// MiddlewareType is the type description of the Middleware and needs to be returned -// in the Middleware interface by the Type method +// MiddlewareType is a type wrapper for a string. It describes the type of the Middleware and needs to be +// returned by the Middleware.Type method to satisfy the Middleware interface. type MiddlewareType string -// Middleware is an interface to define a function to apply to Msg before sending +// Middleware represents the interface for modifying or handling email messages. A Middleware allows the user to +// alter a Msg before it is finally processed. Multiple Middleware can be applied to a Msg. +// +// Type returns a unique MiddlewareType. It describes the type of Middleware and makes sure that +// a Middleware is only applied once. +// Handle performs all the processing to the Msg. It always needs to return a Msg back. type Middleware interface { Handle(*Msg) *Msg Type() MiddlewareType } -// PGPType is a type alias for a int representing a type of PGP encryption -// or signature +// PGPType is a type wrapper for an int, representing a type of PGP encryption or signature. type PGPType int -// Msg is the mail message struct +// Msg represents an email message with various headers, attachments, and encoding settings. +// +// The Msg is the central part of go-mail. It provided a lot of methods that you would expect in a mail +// user agent (MUA). Msg satisfies the io.WriterTo and io.Reader interfaces. type Msg struct { - // addrHeader is a slice of strings that the different mail AddrHeader fields + // addrHeader holds a mapping between AddrHeader keys and their corresponding slices of mail.Address pointers. addrHeader map[AddrHeader][]*mail.Address - // attachments represent the different attachment File of the Msg + // attachments holds a list of File pointers that represent files either as attachments or embeds files in + // a Msg. attachments []*File - // boundary is the MIME content boundary + // boundary represents the delimiter for separating parts in a multipart message. boundary string - // charset represents the charset of the mail (defaults to UTF-8) + // charset represents the Charset of the Msg. + // + // By default we set CharsetUTF8 for a Msg unless overridden by a corresponding MsgOption. charset Charset - // embeds represent the different embedded File of the Msg + // embeds contains a slice of File pointers representing the embedded files in a Msg. embeds []*File - // encoder represents a mime.WordEncoder from the std lib + // encoder is a mime.WordEncoder used to encode strings (such as email headers) using a specified + // Encoding. encoder mime.WordEncoder - // encoding represents the message encoding (the encoder will be a corresponding WordEncoder) + // encoding specifies the type of Encoding used for email messages and/or parts. encoding Encoding - // genHeader is a slice of strings that the different generic mail Header fields + // genHeader is a map where the keys are email headers (of type Header) and the values are slices of strings + // representing header values. genHeader map[Header][]string - // isDelivered signals if a message has been delivered or not + // isDelivered indicates wether the Msg has been delivered. isDelivered bool - // middlewares is the list of middlewares to apply to the Msg before sending in FIFO order + // middlewares is a slice of Middleware used for modifying or handling messages before they are processed. + // + // middlewares are processed in FIFO order. middlewares []Middleware - // mimever represents the MIME version + // mimever represents the MIME version used in a Msg. mimever MIMEVersion - // parts represent the different parts of the Msg + // parts is a slice that holds pointers to Part structures, which represent different parts of a Msg. parts []*Part - // preformHeader is a slice of strings that the different generic mail Header fields - // of which content is already preformated and will not be affected by the automatic line - // breaks + // preformHeader maps Header types to their already preformatted string values. + // + // Preformatted Header values will not be affected by automatic line breaks. preformHeader map[Header]string // pgptype indicates that a message has a PGPType assigned and therefore will generate - // different Content-Type settings in the msgWriter + // different Content-Type settings in the msgWriter. pgptype PGPType - // sendError holds the SendError in case a Msg could not be delivered during the Client.Send operation + // sendError represents an error encountered during the process of sending a Msg during the + // Client.Send operation. + // + // sendError will hold an error of type SendError. sendError error - // noDefaultUserAgent indicates whether the default User Agent will be excluded for the Msg when it's sent. + // noDefaultUserAgent indicates whether the default User-Agent will be omitted for the Msg when it is + // being sent. + // + // This can be useful in scenarios where headers are conditionally passed based on receipt - i. e. SMTP proxies. noDefaultUserAgent bool // SMime represents a middleware used to sign messages with S/MIME sMime *SMime } -// SendmailPath is the default system path to the sendmail binary +// SendmailPath is the default system path to the sendmail binary - at least on standard Unix-like OS. const SendmailPath = "/usr/sbin/sendmail" -// MsgOption returns a function that can be used for grouping Msg options +// MsgOption is a function type that modifies a Msg instance during its creation or initialization. type MsgOption func(*Msg) -// NewMsg returns a new Msg pointer +// NewMsg creates a new email message with optional MsgOption functions that customize various aspects +// of the message. +// +// This function initializes a new Msg instance with default values for address headers, character set, +// encoding, general headers, and MIME version. It then applies any provided MsgOption functions to +// customize the message according to the user's needs. If an option is nil, it will be ignored. +// After applying the options, the function sets the appropriate MIME WordEncoder for the message. +// +// Parameters: +// - opts: A variadic list of MsgOption functions that can be used to customize the Msg instance. +// +// Returns: +// - A pointer to the newly created Msg instance. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5321 func NewMsg(opts ...MsgOption) *Msg { msg := &Msg{ addrHeader: make(map[AddrHeader][]*mail.Address), @@ -143,7 +182,7 @@ func NewMsg(opts ...MsgOption) *Msg { mimever: MIME10, } - // Override defaults with optionally provided MsgOption functions + // Override defaults with optionally provided MsgOption functions. for _, option := range opts { if option == nil { continue @@ -157,49 +196,141 @@ func NewMsg(opts ...MsgOption) *Msg { return msg } -// WithCharset overrides the default message charset -func WithCharset(c Charset) MsgOption { +// WithCharset sets the Charset type for a Msg during its creation or initialization. +// +// This MsgOption function allows you to specify the character set to be used in the email message. +// The charset defines how the text in the message is encoded and interpreted by the email client. +// This option should be called when creating a new Msg instance to ensure that the desired charset +// is set correctly. +// +// Parameters: +// - charset: The Charset value that specifies the desired character set for the Msg. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2047#section-5 +func WithCharset(charset Charset) MsgOption { return func(m *Msg) { - m.charset = c + m.charset = charset } } -// WithEncoding overrides the default message encoding -func WithEncoding(e Encoding) MsgOption { +// WithEncoding sets the Encoding type for a Msg during its creation or initialization. +// +// This MsgOption function allows you to specify the encoding type to be used in the email message. +// The encoding defines how the message content is encoded, which affects how it is transmitted +// and decoded by email clients. This option should be called when creating a new Msg instance to +// ensure that the desired encoding is set correctly. +// +// Parameters: +// - encoding: The Encoding value that specifies the desired encoding type for the Msg. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2047#section-6 +func WithEncoding(encoding Encoding) MsgOption { return func(m *Msg) { - m.encoding = e + m.encoding = encoding } } -// WithMIMEVersion overrides the default MIME version -func WithMIMEVersion(mv MIMEVersion) MsgOption { +// WithMIMEVersion sets the MIMEVersion type for a Msg during its creation or initialization. +// +// Note that in the context of email, MIME Version 1.0 is the only officially standardized and +// supported version. While MIME has been updated and extended over time via various RFCs, these +// updates and extensions do not introduce new MIME versions; they refine or add features within +// the framework of MIME 1.0. Therefore, there should be no reason to ever use this MsgOption. +// +// Parameters: +// - version: The MIMEVersion value that specifies the desired MIME version for the Msg. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc1521 +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2049 +func WithMIMEVersion(version MIMEVersion) MsgOption { return func(m *Msg) { - m.mimever = mv + m.mimever = version } } -// WithBoundary overrides the default MIME boundary -func WithBoundary(b string) MsgOption { +// WithBoundary sets the boundary of a Msg to the provided string value during its creation or +// initialization. +// +// Note that by default, random MIME boundaries are created. This option should only be used if +// a specific boundary is required for the email message. Using a predefined boundary can be +// helpful when constructing multipart messages with specific formatting or content separation. +// +// Parameters: +// - boundary: The string value that specifies the desired boundary for the Msg. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. +func WithBoundary(boundary string) MsgOption { return func(m *Msg) { - m.boundary = b + m.boundary = boundary } } -// WithMiddleware add the given middleware in the end of the list of the client middlewares -func WithMiddleware(mw Middleware) MsgOption { +// WithMiddleware adds the given Middleware to the end of the list of the Client middlewares slice. +// Middleware are processed in FIFO order. +// +// This MsgOption function allows you to specify custom middleware that will be applied during the +// message handling process. Middleware can be used to modify the message, perform logging, or +// implement additional functionality as the message flows through the system. Each middleware +// is executed in the order it was added. +// +// Parameters: +// - middleware: The Middleware to be added to the list for processing. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. +func WithMiddleware(middleware Middleware) MsgOption { return func(m *Msg) { - m.middlewares = append(m.middlewares, mw) + m.middlewares = append(m.middlewares, middleware) } } -// WithPGPType overrides the default PGPType of the message -func WithPGPType(pt PGPType) MsgOption { +// WithPGPType sets the PGP type for the Msg during its creation or initialization, determining +// the encryption or signature method. +// +// This MsgOption function allows you to specify the PGP (Pretty Good Privacy) type to be used +// for securing the message. The chosen PGP type influences how the message is encrypted or +// signed, ensuring confidentiality and integrity of the content. This option should be called +// when creating a new Msg instance to set the desired PGP type appropriately. +// +// Parameters: +// - pgptype: The PGPType value that specifies the desired PGP type for the Msg. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc4880 +func WithPGPType(pgptype PGPType) MsgOption { return func(m *Msg) { - m.pgptype = pt + m.pgptype = pgptype } } -// WithNoDefaultUserAgent configures the Msg to not use the default User Agent +// WithNoDefaultUserAgent disables the inclusion of a default User-Agent header in the Msg during +// its creation or initialization. +// +// This MsgOption function allows you to customize the Msg instance by omitting the default +// User-Agent header, which is typically included to provide information about the software +// sending the email. This option can be useful when you want to have more control over the +// headers included in the message, such as when sending from a custom application or for +// privacy reasons. +// +// Returns: +// - A MsgOption function that can be used to customize the Msg instance. func WithNoDefaultUserAgent() MsgOption { return func(m *Msg) { m.noDefaultUserAgent = true @@ -216,52 +347,149 @@ func (m *Msg) SignWithSMime(keyPair *tls.Certificate) error { return nil } -// SetCharset sets the encoding charset of the Msg -func (m *Msg) SetCharset(c Charset) { - m.charset = c +// This method allows you to specify a character set for the email message. The charset is +// important for ensuring that the content of the message is correctly interpreted by +// mail clients. Common charset values include UTF-8, ISO-8859-1, and others. If a charset +// is not explicitly set, CharsetUTF8 is used as default. +// +// Parameters: +// - charset: The Charset value to set for the Msg, determining the encoding used for the message content. +func (m *Msg) SetCharset(charset Charset) { + m.charset = charset } -// SetEncoding sets the encoding of the Msg -func (m *Msg) SetEncoding(e Encoding) { - m.encoding = e +// SetEncoding sets or overrides the currently set Encoding of the Msg. +// +// This method allows you to specify the encoding type for the email message. The encoding +// determines how the message content is represented and can affect the size and compatibility +// of the email. Common encoding types include Base64 and Quoted-Printable. Setting a new +// encoding may also adjust how the message content is processed and transmitted. +// +// Parameters: +// - encoding: The Encoding value to set for the Msg, determining the method used to encode the +// message content. +func (m *Msg) SetEncoding(encoding Encoding) { + m.encoding = encoding m.setEncoder() } -// SetBoundary sets the boundary of the Msg -func (m *Msg) SetBoundary(b string) { - m.boundary = b +// SetBoundary sets or overrides the currently set boundary of the Msg. +// +// This method allows you to specify a custom boundary string for the MIME message. The +// boundary is used to separate different parts of the message, especially when dealing +// with multipart messages. By default, the Msg generates random MIME boundaries. This +// function should only be used if you have a specific boundary requirement for the +// message. Ensure that the boundary value does not conflict with any content within the +// message to avoid parsing errors. +// +// Parameters: +// - boundary: The string value representing the boundary to set for the Msg, used in +// multipart messages to delimit different sections. +func (m *Msg) SetBoundary(boundary string) { + m.boundary = boundary } -// SetMIMEVersion sets the MIME version of the Msg -func (m *Msg) SetMIMEVersion(mv MIMEVersion) { - m.mimever = mv +// SetMIMEVersion sets or overrides the currently set MIME version of the Msg. +// +// In the context of email, MIME Version 1.0 is the only officially standardized and +// supported version. Although MIME has been updated and extended over time through +// various RFCs, these updates do not introduce new MIME versions; they refine or add +// features within the framework of MIME 1.0. Therefore, there is generally no need to +// use this function to set a different MIME version. +// +// Parameters: +// - version: The MIMEVersion value to set for the Msg, which determines the MIME +// version used in the email message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc1521 +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2049 +func (m *Msg) SetMIMEVersion(version MIMEVersion) { + m.mimever = version } -// SetPGPType sets the PGPType of the Msg -func (m *Msg) SetPGPType(t PGPType) { - m.pgptype = t +// SetPGPType sets or overrides the currently set PGP type for the Msg, determining the +// encryption or signature method. +// +// This method allows you to specify the PGP type that will be used when encrypting or +// signing the message. Different PGP types correspond to various encryption and signing +// algorithms, and selecting the appropriate type is essential for ensuring the security +// and integrity of the message content. +// +// Parameters: +// - pgptype: The PGPType value to set for the Msg, which determines the encryption +// or signature method used for the email message. +func (m *Msg) SetPGPType(pgptype PGPType) { + m.pgptype = pgptype } -// Encoding returns the currently set encoding of the Msg +// Encoding returns the currently set Encoding of the Msg as a string. +// +// This method retrieves the encoding type that is currently applied to the message. The +// encoding type determines how the message content is encoded for transmission. Common +// encoding types include quoted-printable and base64, and the returned string will reflect +// the specific encoding method in use. +// +// Returns: +// - A string representation of the current Encoding of the Msg. func (m *Msg) Encoding() string { return m.encoding.String() } -// Charset returns the currently set charset of the Msg +// Charset returns the currently set Charset of the Msg as a string. +// +// This method retrieves the character set that is currently applied to the message. The +// charset defines the encoding for the text content of the message, ensuring that +// characters are displayed correctly across different email clients and platforms. The +// returned string will reflect the specific charset in use, such as UTF-8 or ISO-8859-1. +// +// Returns: +// - A string representation of the current Charset of the Msg. func (m *Msg) Charset() string { return m.charset.String() } -// SetHeader sets a generic header field of the Msg -// For adding address headers like "To:" or "From", see SetAddrHeader +// SetHeader sets a generic header field of the Msg. // -// Deprecated: This method only exists for compatibility reason. Please use SetGenHeader instead +// Deprecated: This method only exists for compatibility reasons. Please use SetGenHeader +// instead. For adding address headers like "To:" or "From", use SetAddrHeader instead. +// +// This method allows you to set a header field for the message, providing the header name +// and its corresponding values. However, it is recommended to utilize the newer methods +// for better clarity and functionality. Using SetGenHeader or SetAddrHeader is preferred +// for more specific header types, ensuring proper handling of the message headers. +// +// Parameters: +// - header: The header field to set in the Msg. +// - values: One or more string values to associate with the header field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3 +// - https://datatracker.ietf.org/doc/html/rfc2047 func (m *Msg) SetHeader(header Header, values ...string) { m.SetGenHeader(header, values...) } -// SetGenHeader sets a generic header field of the Msg -// For adding address headers like "To:" or "From", see SetAddrHeader +// SetGenHeader sets a generic header field of the Msg to the provided list of values. +// +// This method is intended for setting generic headers in the email message. It takes a +// header name and a variadic list of string values, encoding them as necessary before +// storing them in the message's internal header map. +// +// Note: For adding email address-related headers (like "To:", "From", "Cc", etc.), +// use SetAddrHeader instead to ensure proper formatting and validation. +// +// Parameters: +// - header: The header field to set in the Msg. +// - values: One or more string values to associate with the header field. +// +// This method ensures that all values are appropriately encoded for email transmission, +// adhering to the necessary standards. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3 +// - https://datatracker.ietf.org/doc/html/rfc2047 func (m *Msg) SetGenHeader(header Header, values ...string) { if m.genHeader == nil { m.genHeader = make(map[Header][]string) @@ -272,26 +500,38 @@ func (m *Msg) SetGenHeader(header Header, values ...string) { m.genHeader[header] = values } -// SetHeaderPreformatted sets a generic header field of the Msg which content is -// already preformated. +// SetHeaderPreformatted sets a generic header field of the Msg, which content is already preformatted. // -// Deprecated: This method only exists for compatibility reason. Please use -// SetGenHeaderPreformatted instead +// Deprecated: This method only exists for compatibility reasons. Please use +// SetGenHeaderPreformatted instead for setting preformatted generic header fields. +// +// Parameters: +// - header: The header field to set in the Msg. +// - value: The preformatted string value to associate with the header field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3 +// - https://datatracker.ietf.org/doc/html/rfc2047 func (m *Msg) SetHeaderPreformatted(header Header, value string) { m.SetGenHeaderPreformatted(header, value) } -// SetGenHeaderPreformatted sets a generic header field of the Msg which content is -// already preformated. +// SetGenHeaderPreformatted sets a generic header field of the Msg which content is already preformatted. // -// This method does not take a slice of values but only a single value. This is -// due to the fact, that we do not perform any content alteration and expect the -// user has already done so +// This method does not take a slice of values but only a single value. The reason for this is that we do not +// perform any content alteration on these kinds of headers and expect the user to have already taken care of +// any kind of formatting required for the header. // -// **Please note:** This method should be used only as a last resort. Since the -// user is respondible for the formating of the message header, go-mail cannot -// guarantee the fully compliance with the RFC 2822. It is recommended to use -// SetGenHeader instead. +// Note: This method should be used only as a last resort. Since the user is responsible for the formatting of +// the message header, we cannot guarantee any compliance with RFC 2822. It is advised to use SetGenHeader +// instead for general header fields. +// +// Parameters: +// - header: The header field to set in the Msg. +// - value: The preformatted string value to associate with the header field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2822 func (m *Msg) SetGenHeaderPreformatted(header Header, value string) { if m.preformHeader == nil { m.preformHeader = make(map[Header]string) @@ -299,7 +539,23 @@ func (m *Msg) SetGenHeaderPreformatted(header Header, value string) { m.preformHeader[header] = value } -// SetAddrHeader sets an address related header field of the Msg +// SetAddrHeader sets the specified AddrHeader for the Msg to the given values. +// +// Addresses are parsed according to RFC 5322. If parsing any of the provided values fails, +// an error is returned. If you cannot guarantee that all provided values are valid, you can +// use SetAddrHeaderIgnoreInvalid instead, which will silently skip any parsing errors. +// +// This method allows you to set address-related headers for the message, ensuring that the +// provided addresses are properly formatted and parsed. Using this method helps maintain the +// integrity of the email addresses within the message. +// +// Parameters: +// - header: The AddrHeader to set in the Msg (e.g., "From", "To", "Cc", "Bcc"). +// - values: One or more string values representing the email addresses to associate with +// the specified header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 func (m *Msg) SetAddrHeader(header AddrHeader, values ...string) error { if m.addrHeader == nil { m.addrHeader = make(map[AddrHeader][]*mail.Address) @@ -323,8 +579,21 @@ func (m *Msg) SetAddrHeader(header AddrHeader, values ...string) error { return nil } -// SetAddrHeaderIgnoreInvalid sets an address related header field of the Msg and ignores invalid address -// in the validation process +// SetAddrHeaderIgnoreInvalid sets the specified AddrHeader for the Msg to the given values. +// +// Addresses are parsed according to RFC 5322. If parsing of any of the provided values fails, +// the error is ignored and the address is omitted from the address list. +// +// This method allows for setting address headers while ignoring invalid addresses. It is useful +// in scenarios where you want to ensure that only valid addresses are included without halting +// execution due to parsing errors. +// +// Parameters: +// - header: The AddrHeader field to set in the Msg. +// - values: One or more string values representing email addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) { var addresses []*mail.Address for _, addrVal := range values { @@ -337,114 +606,341 @@ func (m *Msg) SetAddrHeaderIgnoreInvalid(header AddrHeader, values ...string) { m.addrHeader[header] = addresses } -// EnvelopeFrom takes and validates a given mail address and sets it as envelope "FROM" -// addrHeader of the Msg +// EnvelopeFrom sets the envelope from address for the Msg. +// +// The HeaderEnvelopeFrom address is generally not included in the mail body but only used by the +// Client for communication with the SMTP server. If the Msg has no "FROM" address set in the +// mail body, the msgWriter will try to use the envelope from address if it has been set for the Msg. +// The provided address is validated according to RFC 5322 and will return an error if the validation fails. +// +// Parameters: +// - from: The envelope from address to set in the Msg. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 func (m *Msg) EnvelopeFrom(from string) error { return m.SetAddrHeader(HeaderEnvelopeFrom, from) } -// EnvelopeFromFormat takes a name and address, formats them RFC5322 compliant and stores them as -// the envelope FROM address header field +// EnvelopeFromFormat sets the provided name and mail address as HeaderEnvelopeFrom for the Msg. +// +// The HeaderEnvelopeFrom address is generally not included in the mail body but only used by the +// Client for communication with the SMTP server. If the Msg has no "FROM" address set in the mail +// body, the msgWriter will try to use the envelope from address if it has been set for the Msg. +// The provided name and address are validated according to RFC 5322 and will return an error if +// the validation fails. +// +// Parameters: +// - name: The name to associate with the envelope from address. +// - addr: The mail address to set as the envelope from address. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 func (m *Msg) EnvelopeFromFormat(name, addr string) error { return m.SetAddrHeader(HeaderEnvelopeFrom, fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// From takes and validates a given mail address and sets it as "From" genHeader of the Msg +// From sets the "FROM" address in the mail body for the Msg. +// +// The "FROM" address is included in the mail body and indicates the sender of the message to +// the recipient. This address is visible in the email client and is typically displayed to the +// recipient. If the "FROM" address is not set, the msgWriter may attempt to use the envelope +// from address (if available) for sending. The provided address is validated according to RFC +// 5322 and will return an error if the validation fails. +// +// Parameters: +// - from: The "FROM" address to set in the mail body. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) From(from string) error { return m.SetAddrHeader(HeaderFrom, from) } -// FromFormat takes a name and address, formats them RFC5322 compliant and stores them as -// the From address header field +// FromFormat sets the provided name and mail address as the "FROM" address in the mail body for the Msg. +// +// The "FROM" address is included in the mail body and indicates the sender of the message to +// the recipient, and is visible in the email client. If the "FROM" address is not explicitly +// set, the msgWriter may use the envelope from address (if provided) when sending the message. +// The provided name and address are validated according to RFC 5322 and will return an error +// if the validation fails. +// +// Parameters: +// - name: The name of the sender to include in the "FROM" address. +// - addr: The email address of the sender to include in the "FROM" address. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) FromFormat(name, addr string) error { return m.SetAddrHeader(HeaderFrom, fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// To takes and validates a given mail address list sets the To: addresses of the Msg +// To sets one or more "TO" addresses in the mail body for the Msg. +// +// The "TO" address specifies the primary recipient(s) of the message and is included in the mail body. +// This address is visible to the recipient and any other recipients of the message. Multiple "TO" addresses +// can be set by passing them as variadic arguments to this method. Each provided address is validated +// according to RFC 5322, and an error will be returned if ANY validation fails. +// +// Parameters: +// - rcpts: One or more recipient email addresses to include in the "TO" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) To(rcpts ...string) error { return m.SetAddrHeader(HeaderTo, rcpts...) } -// AddTo adds an additional address to the To address header field +// AddTo adds a single "TO" address to the existing list of recipients in the mail body for the Msg. +// +// This method allows you to add a single recipient to the "TO" field without replacing any previously set +// "TO" addresses. The "TO" address specifies the primary recipient(s) of the message and is visible in the mail +// client. The provided address is validated according to RFC 5322, and an error will be returned if the +// validation fails. +// +// Parameters: +// - rcpt: The recipient email address to add to the "TO" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) AddTo(rcpt string) error { return m.addAddr(HeaderTo, rcpt) } -// AddToFormat takes a name and address, formats them RFC5322 compliant and stores them as -// as additional To address header field +// AddToFormat adds a single "TO" address with the provided name and email to the existing list of recipients +// in the mail body for the Msg. +// +// This method allows you to add a recipient's name and email address to the "TO" field without replacing any +// previously set "TO" addresses. The "TO" address specifies the primary recipient(s) of the message and is +// visible in the mail client. The provided name and address are validated according to RFC 5322, and an error +// will be returned if the validation fails. +// +// Parameters: +// - name: The name of the recipient to add to the "TO" field. +// - addr: The email address of the recipient to add to the "TO" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) AddToFormat(name, addr string) error { return m.addAddr(HeaderTo, fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// ToIgnoreInvalid takes and validates a given mail address list sets the To: addresses of the Msg -// Any provided address that is not RFC5322 compliant, will be ignored +// ToIgnoreInvalid sets one or more "TO" addresses in the mail body for the Msg, ignoring any invalid addresses. +// +// This method allows you to add multiple "TO" recipients to the message body. Unlike the standard `To` method, +// any invalid addresses are ignored, and no error is returned for those addresses. Valid addresses will still be +// included in the "TO" field, which is visible in the recipient's mail client. Use this method with caution if +// address validation is critical. Invalid addresses are determined according to RFC 5322. +// +// Parameters: +// - rcpts: One or more recipient addresses to add to the "TO" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) ToIgnoreInvalid(rcpts ...string) { m.SetAddrHeaderIgnoreInvalid(HeaderTo, rcpts...) } -// ToFromString takes and validates a given string of comma separted -// mail address and sets them as To: addresses of the Msg +// ToFromString takes a string of comma-separated email addresses, validates each, and sets them as the +// "TO" addresses for the Msg. +// +// This method allows you to pass a single string containing multiple email addresses separated by commas. +// Each address is validated according to RFC 5322 and set as a recipient in the "TO" field. If any validation +// fails, an error will be returned. The addresses are visible in the mail body and displayed to recipients in +// the mail client. Any "TO" address applied previously will be overwritten. +// +// Parameters: +// - rcpts: A string containing multiple recipient addresses separated by commas. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) ToFromString(rcpts string) error { return m.To(strings.Split(rcpts, ",")...) } -// Cc takes and validates a given mail address list sets the Cc: addresses of the Msg +// Cc sets one or more "CC" (carbon copy) addresses in the mail body for the Msg. +// +// The "CC" address specifies secondary recipient(s) of the message, and is included in the mail body. +// These addresses are visible to all recipients, including those listed in the "TO" and other "CC" fields. +// Multiple "CC" addresses can be set by passing them as variadic arguments to this method. Each provided +// address is validated according to RFC 5322, and an error will be returned if ANY validation fails. +// +// Parameters: +// - rcpts: One or more recipient addresses to be included in the "CC" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) Cc(rcpts ...string) error { return m.SetAddrHeader(HeaderCc, rcpts...) } -// AddCc adds an additional address to the Cc address header field +// AddCc adds a single "CC" (carbon copy) address to the existing list of "CC" recipients in the mail body +// for the Msg. +// +// This method allows you to add a single recipient to the "CC" field without replacing any previously set "CC" +// addresses. The "CC" address specifies secondary recipient(s) and is visible to all recipients, including those +// in the "TO" field. The provided address is validated according to RFC 5322, and an error will be returned if +// the validation fails. +// +// Parameters: +// - rcpt: The recipient address to be added to the "CC" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) AddCc(rcpt string) error { return m.addAddr(HeaderCc, rcpt) } -// AddCcFormat takes a name and address, formats them RFC5322 compliant and stores them as -// as additional Cc address header field +// AddCcFormat adds a single "CC" (carbon copy) address with the provided name and email to the existing list +// of "CC" recipients in the mail body for the Msg. +// +// This method allows you to add a recipient's name and email address to the "CC" field without replacing any +// previously set "CC" addresses. The "CC" address specifies secondary recipient(s) and is visible to all +// recipients, including those in the "TO" field. The provided name and address are validated according to +// RFC 5322, and an error will be returned if the validation fails. +// +// Parameters: +// - name: The name of the recipient to be added to the "CC" field. +// - addr: The email address of the recipient to be added to the "CC" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) AddCcFormat(name, addr string) error { return m.addAddr(HeaderCc, fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// CcIgnoreInvalid takes and validates a given mail address list sets the Cc: addresses of the Msg -// Any provided address that is not RFC5322 compliant, will be ignored +// CcIgnoreInvalid sets one or more "CC" (carbon copy) addresses in the mail body for the Msg, ignoring any +// invalid addresses. +// +// This method allows you to add multiple "CC" recipients to the message body. Unlike the standard `Cc` method, +// any invalid addresses are ignored, and no error is returned for those addresses. Valid addresses will still +// be included in the "CC" field, which is visible to all recipients in the mail client. Use this method with +// caution if address validation is critical, as invalid addresses are determined according to RFC 5322. +// +// Parameters: +// - rcpts: One or more recipient email addresses to be added to the "CC" field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) CcIgnoreInvalid(rcpts ...string) { m.SetAddrHeaderIgnoreInvalid(HeaderCc, rcpts...) } -// CcFromString takes and validates a given string of comma separted -// mail address and sets them as Cc: addresses of the Msg +// CcFromString takes a string of comma-separated email addresses, validates each, and sets them as the "CC" +// addresses for the Msg. +// +// This method allows you to pass a single string containing multiple email addresses separated by commas. +// Each address is validated according to RFC 5322 and set as a recipient in the "CC" field. If any validation +// fails, an error will be returned. The addresses are visible in the mail body and displayed to recipients +// in the mail client. +// +// Parameters: +// - rcpts: A string containing multiple email addresses separated by commas. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) CcFromString(rcpts string) error { return m.Cc(strings.Split(rcpts, ",")...) } -// Bcc takes and validates a given mail address list sets the Bcc: addresses of the Msg +// Bcc sets one or more "BCC" (blind carbon copy) addresses in the mail body for the Msg. +// +// The "BCC" address specifies recipient(s) of the message who will receive a copy without other recipients +// being aware of it. These addresses are not visible in the mail body or to any other recipients, ensuring +// the privacy of BCC'd recipients. Multiple "BCC" addresses can be set by passing them as variadic arguments +// to this method. Each provided address is validated according to RFC 5322, and an error will be returned +// if ANY validation fails. +// +// Parameters: +// - rcpts: One or more string values representing the BCC addresses to set in the Msg. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) Bcc(rcpts ...string) error { return m.SetAddrHeader(HeaderBcc, rcpts...) } -// AddBcc adds an additional address to the Bcc address header field +// AddBcc adds a single "BCC" (blind carbon copy) address to the existing list of "BCC" recipients in the mail +// body for the Msg. +// +// This method allows you to add a single recipient to the "BCC" field without replacing any previously set +// "BCC" addresses. The "BCC" address specifies recipient(s) of the message who will receive a copy without other +// recipients being aware of it. The provided address is validated according to RFC 5322, and an error will be +// returned if the validation fails. +// +// Parameters: +// - rcpt: The BCC address to add to the existing list of recipients in the Msg. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) AddBcc(rcpt string) error { return m.addAddr(HeaderBcc, rcpt) } -// AddBccFormat takes a name and address, formats them RFC5322 compliant and stores them as -// as additional Bcc address header field +// AddBccFormat adds a single "BCC" (blind carbon copy) address with the provided name and email to the existing +// list of "BCC" recipients in the mail body for the Msg. +// +// This method allows you to add a recipient's name and email address to the "BCC" field without replacing +// any previously set "BCC" addresses. The "BCC" address specifies recipient(s) of the message who will receive +// a copy without other recipients being aware of it. The provided name and address are validated according to +// RFC 5322, and an error will be returned if the validation fails. +// +// Parameters: +// - name: The name of the recipient to add to the BCC field. +// - addr: The email address of the recipient to add to the BCC field. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) AddBccFormat(name, addr string) error { return m.addAddr(HeaderBcc, fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// BccIgnoreInvalid takes and validates a given mail address list sets the Bcc: addresses of the Msg -// Any provided address that is not RFC5322 compliant, will be ignored +// BccIgnoreInvalid sets one or more "BCC" (blind carbon copy) addresses in the mail body for the Msg, +// ignoring any invalid addresses. +// +// This method allows you to add multiple "BCC" recipients to the message body. Unlike the standard `Bcc` +// method, any invalid addresses are ignored, and no error is returned for those addresses. Valid addresses +// will still be included in the "BCC" field, which ensures the privacy of the BCC'd recipients. Use this method +// with caution if address validation is critical, as invalid addresses are determined according to RFC 5322. +// +// Parameters: +// - rcpts: One or more string values representing the BCC email addresses to set. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) BccIgnoreInvalid(rcpts ...string) { m.SetAddrHeaderIgnoreInvalid(HeaderBcc, rcpts...) } -// BccFromString takes and validates a given string of comma separted -// mail address and sets them as Bcc: addresses of the Msg +// BccFromString takes a string of comma-separated email addresses, validates each, and sets them as the "BCC" +// addresses for the Msg. +// +// This method allows you to pass a single string containing multiple email addresses separated by commas. +// Each address is validated according to RFC 5322 and set as a recipient in the "BCC" field. If any validation +// fails, an error will be returned. The addresses are not visible in the mail body and ensure the privacy of +// BCC'd recipients. +// +// Parameters: +// - rcpts: A string of comma-separated email addresses to set as BCC recipients. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) BccFromString(rcpts string) error { return m.Bcc(strings.Split(rcpts, ",")...) } -// ReplyTo takes and validates a given mail address and sets it as "Reply-To" addrHeader of the Msg +// ReplyTo sets the "Reply-To" address for the Msg, specifying where replies should be sent. +// +// This method takes a single email address as input and attempts to parse it. If the address is valid, it sets +// the "Reply-To" header in the message. The "Reply-To" address can be different from the "From" address, +// allowing the sender to specify an alternate address for responses. If the provided address cannot be parsed, +// an error will be returned, indicating the parsing failure. +// +// Parameters: +// - addr: The email address to set as the "Reply-To" address. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) ReplyTo(addr string) error { replyTo, err := mail.ParseAddress(addr) if err != nil { @@ -454,68 +950,151 @@ func (m *Msg) ReplyTo(addr string) error { return nil } -// ReplyToFormat takes a name and address, formats them RFC5322 compliant and stores them as -// the Reply-To header field +// ReplyToFormat sets the "Reply-To" address for the Msg using the provided name and email address, specifying +// where replies should be sent. +// +// This method formats the name and email address into a single "Reply-To" header. If the formatted address is valid, +// it sets the "Reply-To" header in the message. This allows the sender to specify a display name along with the +// reply address, providing clarity for recipients. If the constructed address cannot be parsed, an error will +// be returned, indicating the parsing failure. +// +// Parameters: +// - name: The display name associated with the reply address. +// - addr: The email address to set as the "Reply-To" address. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) ReplyToFormat(name, addr string) error { return m.ReplyTo(fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// addAddr adds an additional address to the given addrHeader of the Msg -func (m *Msg) addAddr(header AddrHeader, addr string) error { - var addresses []string - for _, address := range m.addrHeader[header] { - addresses = append(addresses, address.String()) - } - addresses = append(addresses, addr) - return m.SetAddrHeader(header, addresses...) -} - -// Subject sets the "Subject" header field of the Msg +// Subject sets the "Subject" header for the Msg, specifying the topic of the message. +// +// This method takes a single string as input and sets it as the "Subject" of the email. The subject line provides +// a brief summary of the content of the message, allowing recipients to quickly understand its purpose. +// +// Parameters: +// - subj: The subject line of the email. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.5 func (m *Msg) Subject(subj string) { m.SetGenHeader(HeaderSubject, subj) } -// SetMessageID generates a random message id for the mail +// SetMessageID generates and sets a unique "Message-ID" header for the Msg. +// +// This method creates a "Message-ID" string using a randomly generated string and the hostname of the machine. +// The generated ID helps uniquely identify the message in email systems, facilitating tracking and preventing +// duplication. If the hostname cannot be retrieved, it defaults to "localhost.localdomain". +// +// The generated Message-ID follows the format +// "". +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.4 func (m *Msg) SetMessageID() { hostname, err := os.Hostname() if err != nil { hostname = "localhost.localdomain" } - randNumPrimary, _ := randNum(100000000) - randNumSecondary, _ := randNum(10000) - randString, _ := randomStringSecure(17) - procID := os.Getpid() * randNumSecondary - messageID := fmt.Sprintf("%d.%d%d.%s@%s", procID, randNumPrimary, randNumSecondary, - randString, hostname) - m.SetMessageIDWithValue(messageID) + // We have 64 possible characters, which for a 22 character string, provides approx. 132 bits of entropy. + randString, _ := randomStringSecure(22) + m.SetMessageIDWithValue(fmt.Sprintf("%s@%s", randString, hostname)) } -// SetMessageIDWithValue sets the message id for the mail +// GetMessageID retrieves the "Message-ID" header from the Msg. +// +// This method checks if a "Message-ID" has been set in the message's generated headers. If a valid "Message-ID" +// exists in the Msg, it returns the first occurrence of the header. If the "Message-ID" has not been set or +// is empty, it returns an empty string. This allows other components to access the unique identifier for the +// message, which is useful for tracking and referencing in email systems. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.4 +func (m *Msg) GetMessageID() string { + if msgidheader, ok := m.genHeader[HeaderMessageID]; ok { + if len(msgidheader) > 0 { + return msgidheader[0] + } + } + return "" +} + +// SetMessageIDWithValue sets the "Message-ID" header for the Msg using the provided messageID string. +// +// This method formats the input messageID by enclosing it in angle brackets ("<>") and sets it as the "Message-ID" +// header in the message. The "Message-ID" is a unique identifier for the email, helping email clients and servers +// to track and reference the message. There are no validations performed on the input messageID, so it should +// be in a suitable format for use as a Message-ID. +// +// Parameters: +// - messageID: The string to set as the "Message-ID" in the message header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.4 func (m *Msg) SetMessageIDWithValue(messageID string) { m.SetGenHeader(HeaderMessageID, fmt.Sprintf("<%s>", messageID)) } -// SetBulk sets the "Precedence: bulk" and "X-Auto-Response-Suppress: All" genHeaders which are -// recommended for automated mails like OOO replies -// See: https://www.rfc-editor.org/rfc/rfc2076#section-3.9 -// See also: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcmail/ced68690-498a-4567-9d14-5c01f974d8b1#Appendix_A_Target_51 +// SetBulk sets the "Precedence: bulk" and "X-Auto-Response-Suppress: All" headers for the Msg, +// which are recommended for automated emails such as out-of-office replies. +// +// The "Precedence: bulk" header indicates that the message is a bulk email, and the "X-Auto-Response-Suppress: All" +// header instructs mail servers and clients to suppress automatic responses to this message. +// This is particularly useful for reducing unnecessary replies to automated notifications or replies. +// +// References: +// - https://www.rfc-editor.org/rfc/rfc2076#section-3.9 +// - https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcmail/ced68690-498a-4567-9d14-5c01f974d8b1#Appendix_A_Target_51 func (m *Msg) SetBulk() { m.SetGenHeader(HeaderPrecedence, "bulk") m.SetGenHeader(HeaderXAutoResponseSuppress, "All") } -// SetDate sets the Date genHeader field to the current time in a valid format +// SetDate sets the "Date" header for the Msg to the current time in a valid RFC 1123 format. +// +// This method retrieves the current time and formats it according to RFC 1123, ensuring that the "Date" +// header is compliant with email standards. The "Date" header indicates when the message was created, +// providing recipients with context for the timing of the email. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.3 +// - https://datatracker.ietf.org/doc/html/rfc1123 func (m *Msg) SetDate() { now := time.Now().Format(time.RFC1123Z) m.SetGenHeader(HeaderDate, now) } -// SetDateWithValue sets the Date genHeader field to the provided time in a valid format +// SetDateWithValue sets the "Date" header for the Msg using the provided time value in a valid RFC 1123 format. +// +// This method takes a `time.Time` value as input and formats it according to RFC 1123, ensuring that the "Date" +// header is compliant with email standards. The "Date" header indicates when the message was created, +// providing recipients with context for the timing of the email. This allows for setting a custom date +// rather than using the current time. +// +// Parameters: +// - timeVal: The time value used to set the "Date" header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.3 +// - https://datatracker.ietf.org/doc/html/rfc1123 func (m *Msg) SetDateWithValue(timeVal time.Time) { m.SetGenHeader(HeaderDate, timeVal.Format(time.RFC1123Z)) } -// SetImportance sets the Msg Importance/Priority header to given Importance +// SetImportance sets the "Importance" and "Priority" headers for the Msg to the specified Importance level. +// +// This method adjusts the email's importance based on the provided Importance value. If the importance level +// is set to `ImportanceNormal`, no headers are modified. Otherwise, it sets the "Importance", "Priority", +// "X-Priority", and "X-MSMail-Priority" headers accordingly, providing email clients with information on +// how to prioritize the message. This allows the sender to indicate the significance of the email to recipients. +// +// Parameters: +// - importance: The Importance value that determines the priority of the email message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2156 func (m *Msg) SetImportance(importance Importance) { if importance == ImportanceNormal { return @@ -526,26 +1105,63 @@ func (m *Msg) SetImportance(importance Importance) { m.SetGenHeader(HeaderXMSMailPriority, importance.NumString()) } -// SetOrganization sets the provided string as Organization header for the Msg +// SetOrganization sets the "Organization" header for the Msg to the specified organization string. +// +// This method allows you to specify the organization associated with the email sender. The "Organization" +// header provides recipients with information about the organization that is sending the message. +// This can help establish context and credibility for the email communication. +// +// Parameters: +// - org: The name of the organization to be set in the "Organization" header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.4 func (m *Msg) SetOrganization(org string) { m.SetGenHeader(HeaderOrganization, org) } -// SetUserAgent sets the User-Agent/X-Mailer header for the Msg +// SetUserAgent sets the "User-Agent" and "X-Mailer" headers for the Msg to the specified user agent string. +// +// This method allows you to specify the user agent or mailer software used to send the email. +// The "User-Agent" and "X-Mailer" headers provide recipients with information about the email client +// or application that generated the message. This can be useful for identifying the source of the email, +// particularly for troubleshooting or filtering purposes. +// +// Parameters: +// - userAgent: The user agent or mailer software to be set in the "User-Agent" and "X-Mailer" headers. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.7 func (m *Msg) SetUserAgent(userAgent string) { m.SetGenHeader(HeaderUserAgent, userAgent) m.SetGenHeader(HeaderXMailer, userAgent) } -// IsDelivered will return true if the Msg has been successfully delivered +// IsDelivered indicates whether the Msg has been delivered. +// +// This method checks the internal state of the message to determine if it has been successfully +// delivered. It returns true if the message is marked as delivered and false otherwise. +// This can be useful for tracking the status of the email communication. +// +// Returns: +// - A boolean value indicating the delivery status of the message (true if delivered, false otherwise). func (m *Msg) IsDelivered() bool { return m.isDelivered } -// RequestMDNTo adds the Disposition-Notification-To header to request a MDN from the receiving end -// as described in RFC8098. It allows to provide a list recipient addresses. -// Address validation is performed -// See: https://www.rfc-editor.org/rfc/rfc8098.html +// RequestMDNTo adds the "Disposition-Notification-To" header to the Msg to request a Message Disposition +// Notification (MDN) from the receiving end, as specified in RFC 8098. +// +// This method allows you to provide a list of recipient addresses to receive the MDN. +// Each address is validated according to RFC 5322 standards. If ANY address is invalid, an error +// will be returned indicating the parsing failure. If the "Disposition-Notification-To" header +// is already set, it will be updated with the new list of addresses. +// +// Parameters: +// - rcpts: One or more recipient email addresses to request the MDN from. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc8098 func (m *Msg) RequestMDNTo(rcpts ...string) error { var addresses []string for _, addrVal := range rcpts { @@ -561,15 +1177,35 @@ func (m *Msg) RequestMDNTo(rcpts ...string) error { return nil } -// RequestMDNToFormat adds the Disposition-Notification-To header to request a MDN from the receiving end -// as described in RFC8098. It allows to provide a recipient address with name and address and will format -// accordingly. Address validation is performed -// See: https://www.rfc-editor.org/rfc/rfc8098.html +// RequestMDNToFormat adds the "Disposition-Notification-To" header to the Msg to request a Message Disposition +// Notification (MDN) from the receiving end, as specified in RFC 8098. +// +// This method allows you to provide a recipient address along with a name, formatting it appropriately. +// Address validation is performed according to RFC 5322 standards. If the provided address is invalid, +// an error will be returned. This method internally calls RequestMDNTo to handle the actual setting of the header. +// +// Parameters: +// - name: The name of the recipient for the MDN request. +// - addr: The email address of the recipient for the MDN request. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc8098 func (m *Msg) RequestMDNToFormat(name, addr string) error { return m.RequestMDNTo(fmt.Sprintf(`%s <%s>`, name, addr)) } -// RequestMDNAddTo adds an additional recipient to the recipient list of the MDN +// RequestMDNAddTo adds an additional recipient to the "Disposition-Notification-To" header for the Msg. +// +// This method allows you to append a new recipient address to the existing list of recipients for the +// MDN. The provided address is validated according to RFC 5322 standards. If the address is invalid, +// an error will be returned indicating the parsing failure. If the "Disposition-Notification-To" +// header is already set, the new recipient will be added to the existing list. +// +// Parameters: +// - rcpt: The recipient email address to add to the "Disposition-Notification-To" header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc8098 func (m *Msg) RequestMDNAddTo(rcpt string) error { address, err := mail.ParseAddress(rcpt) if err != nil { @@ -584,14 +1220,41 @@ func (m *Msg) RequestMDNAddTo(rcpt string) error { return nil } -// RequestMDNAddToFormat adds an additional formated recipient to the recipient list of the MDN +// RequestMDNAddToFormat adds an additional formatted recipient to the "Disposition-Notification-To" +// header for the Msg. +// +// This method allows you to specify a recipient address along with a name, formatting it appropriately +// before adding it to the existing list of recipients for the MDN. The formatted address is validated +// according to RFC 5322 standards. If the provided address is invalid, an error will be returned. +// This method internally calls RequestMDNAddTo to handle the actual addition of the recipient. +// +// Parameters: +// - name: The name of the recipient to add to the "Disposition-Notification-To" header. +// - addr: The email address of the recipient to add to the "Disposition-Notification-To" header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc8098 func (m *Msg) RequestMDNAddToFormat(name, addr string) error { return m.RequestMDNAddTo(fmt.Sprintf(`"%s" <%s>`, name, addr)) } -// GetSender returns the currently set envelope FROM address. If no envelope FROM is set it will use -// the first mail body FROM address. If useFullAddr is true, it will return the full address string -// including the address name, if set +// GetSender returns the currently set envelope "FROM" address for the Msg. If no envelope +// "FROM" address is set, it will use the first "FROM" address from the mail body. If the +// useFullAddr parameter is true, it will return the full address string, including the name +// if it is set. +// +// If neither the envelope "FROM" nor the body "FROM" addresses are available, it will return +// an error indicating that no "FROM" address is present. +// +// Parameters: +// - useFullAddr: A boolean indicating whether to return the full address string (including +// the name) or just the email address. +// +// Returns: +// - The sender's address as a string and an error if applicable. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) GetSender(useFullAddr bool) (string, error) { from, ok := m.addrHeader[HeaderEnvelopeFrom] if !ok || len(from) == 0 { @@ -606,7 +1269,19 @@ func (m *Msg) GetSender(useFullAddr bool) (string, error) { return from[0].Address, nil } -// GetRecipients returns a list of the currently set TO/CC/BCC addresses. +// GetRecipients returns a list of the currently set "TO", "CC", and "BCC" addresses for the Msg. +// +// This method aggregates recipients from the "TO", "CC", and "BCC" headers and returns them as a +// slice of strings. If no recipients are found in these headers, it will return an error indicating +// that no recipient addresses are present. +// +// Returns: +// - A slice of strings containing the recipients' addresses and an error if applicable. +// - If there are no recipient addresses set, it will return an error indicating no recipient +// addresses are available. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetRecipients() ([]string, error) { var rcpts []string for _, addressType := range []AddrHeader{HeaderTo, HeaderCc, HeaderBcc} { @@ -624,12 +1299,42 @@ func (m *Msg) GetRecipients() ([]string, error) { return rcpts, nil } -// GetAddrHeader returns the content of the requested address header of the Msg +// GetAddrHeader returns the content of the requested address header for the Msg. +// +// This method retrieves the addresses associated with the specified address header. It returns a +// slice of pointers to mail.Address structures representing the addresses found in the header. +// If the requested header does not exist or contains no addresses, it will return nil. +// +// Parameters: +// - header: The AddrHeader enum value indicating which address header to retrieve (e.g., "TO", +// "CC", "BCC", etc.). +// +// Returns: +// - A slice of pointers to mail.Address structures containing the addresses from the specified +// header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6 func (m *Msg) GetAddrHeader(header AddrHeader) []*mail.Address { return m.addrHeader[header] } -// GetAddrHeaderString returns the address string of the requested address header of the Msg +// GetAddrHeaderString returns the address strings of the requested address header for the Msg. +// +// This method retrieves the addresses associated with the specified address header and returns them +// as a slice of strings. Each address is formatted as a string, which includes both the name (if +// available) and the email address. If the requested header does not exist or contains no addresses, +// it will return an empty slice. +// +// Parameters: +// - header: The AddrHeader enum value indicating which address header to retrieve (e.g., "TO", +// "CC", "BCC", etc.). +// +// Returns: +// - A slice of strings containing the formatted addresses from the specified header. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6 func (m *Msg) GetAddrHeaderString(header AddrHeader) []string { var addresses []string for _, mh := range m.addrHeader[header] { @@ -638,67 +1343,179 @@ func (m *Msg) GetAddrHeaderString(header AddrHeader) []string { return addresses } -// GetFrom returns the content of the From address header of the Msg +// GetFrom returns the content of the "From" address header of the Msg. +// +// This method retrieves the list of email addresses set in the "From" header of the message. +// It returns a slice of pointers to `mail.Address` objects representing the sender(s) of the email. +// +// Returns: +// - A slice of `*mail.Address` containing the "From" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) GetFrom() []*mail.Address { return m.GetAddrHeader(HeaderFrom) } -// GetFromString returns the content of the From address header of the Msg as string slice +// GetFromString returns the content of the "From" address header of the Msg as a string slice. +// +// This method retrieves the list of email addresses set in the "From" header of the message +// and returns them as a slice of strings, with each entry representing a formatted email address. +// +// Returns: +// - A slice of strings containing the "From" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.2 func (m *Msg) GetFromString() []string { return m.GetAddrHeaderString(HeaderFrom) } -// GetTo returns the content of the To address header of the Msg +// GetTo returns the content of the "To" address header of the Msg. +// +// This method retrieves the list of email addresses set in the "To" header of the message. +// It returns a slice of pointers to `mail.Address` objects representing the primary recipient(s) of the email. +// +// Returns: +// - A slice of `*mail.Address` containing the "To" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetTo() []*mail.Address { return m.GetAddrHeader(HeaderTo) } -// GetToString returns the content of the To address header of the Msg as string slice +// GetToString returns the content of the "To" address header of the Msg as a string slice. +// +// This method retrieves the list of email addresses set in the "To" header of the message +// and returns them as a slice of strings, with each entry representing a formatted email address. +// +// Returns: +// - A slice of strings containing the "To" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetToString() []string { return m.GetAddrHeaderString(HeaderTo) } -// GetCc returns the content of the Cc address header of the Msg +// GetCc returns the content of the "Cc" address header of the Msg. +// +// This method retrieves the list of email addresses set in the "Cc" (carbon copy) header of the message. +// It returns a slice of pointers to `mail.Address` objects representing the secondary recipient(s) of the email. +// +// Returns: +// - A slice of `*mail.Address` containing the "Cc" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetCc() []*mail.Address { return m.GetAddrHeader(HeaderCc) } -// GetCcString returns the content of the Cc address header of the Msg as string slice +// GetCcString returns the content of the "Cc" address header of the Msg as a string slice. +// +// This method retrieves the list of email addresses set in the "Cc" (carbon copy) header of the message +// and returns them as a slice of strings, with each entry representing a formatted email address. +// +// Returns: +// - A slice of strings containing the "Cc" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetCcString() []string { return m.GetAddrHeaderString(HeaderCc) } -// GetBcc returns the content of the Bcc address header of the Msg +// GetBcc returns the content of the "Bcc" address header of the Msg. +// +// This method retrieves the list of email addresses set in the "Bcc" (blind carbon copy) header of the message. +// It returns a slice of pointers to `mail.Address` objects representing the Bcc recipient(s) of the email. +// +// Returns: +// - A slice of `*mail.Address` containing the "Bcc" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetBcc() []*mail.Address { return m.GetAddrHeader(HeaderBcc) } -// GetBccString returns the content of the Bcc address header of the Msg as string slice +// GetBccString returns the content of the "Bcc" address header of the Msg as a string slice. +// +// This method retrieves the list of email addresses set in the "Bcc" (blind carbon copy) header of the message +// and returns them as a slice of strings, with each entry representing a formatted email address. +// +// Returns: +// - A slice of strings containing the "Bcc" header addresses. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.3 func (m *Msg) GetBccString() []string { return m.GetAddrHeaderString(HeaderBcc) } -// GetGenHeader returns the content of the requested generic header of the Msg +// GetGenHeader returns the content of the requested generic header of the Msg. +// +// This method retrieves the list of string values associated with the specified generic header of the message. +// It returns a slice of strings representing the header's values. +// +// Parameters: +// - header: The Header field whose values are being retrieved. +// +// Returns: +// - A slice of strings containing the values of the specified generic header. func (m *Msg) GetGenHeader(header Header) []string { return m.genHeader[header] } -// GetParts returns the message parts of the Msg +// GetParts returns the message parts of the Msg. +// +// This method retrieves the list of parts that make up the email message. Each part may represent +// a different section of the email, such as a plain text body, HTML body, or attachments. +// +// Returns: +// - A slice of Part pointers representing the message parts of the email. func (m *Msg) GetParts() []*Part { return m.parts } -// GetAttachments returns the attachments of the Msg +// GetAttachments returns the attachments of the Msg. +// +// This method retrieves the list of files that have been attached to the email message. +// Each attachment includes details about the file, such as its name, content type, and data. +// +// Returns: +// - A slice of File pointers representing the attachments of the email. func (m *Msg) GetAttachments() []*File { return m.attachments } -// GetBoundary returns the boundary of the Msg +// GetBoundary returns the boundary of the Msg. +// +// This method retrieves the MIME boundary that is used to separate different parts of the message, +// particularly in multipart emails. The boundary helps to differentiate between various sections +// such as plain text, HTML content, and attachments. +// +// Returns: +// - A string representing the boundary of the message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.1 func (m *Msg) GetBoundary() string { return m.boundary } // SetAttachments sets the attachments of the message. +// +// This method allows you to specify the attachments for the message by providing a slice of File pointers. +// Each file represents an attachment that will be included in the email. +// +// Parameters: +// - files: A slice of pointers to File structures representing the attachments to set for the message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) SetAttachments(files []*File) { m.attachments = files } @@ -710,33 +1527,83 @@ func (m *Msg) SetAttachements(files []*File) { m.SetAttachments(files) } -// UnsetAllAttachments unset the attachments of the message. +// UnsetAllAttachments unsets the attachments of the message. +// +// This method removes all attachments from the message by setting the attachments to nil, effectively +// clearing any previously set attachments. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) UnsetAllAttachments() { m.attachments = nil } -// GetEmbeds returns the embeds of the Msg +// GetEmbeds returns the embedded files of the Msg. +// +// This method retrieves the list of files that have been embedded in the message. Embeds are typically +// images or other media files that are referenced directly in the content of the email, such as inline +// images in HTML emails. +// +// Returns: +// - A slice of pointers to File structures representing the embedded files in the message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) GetEmbeds() []*File { return m.embeds } -// SetEmbeds sets the embeds of the message. +// SetEmbeds sets the embedded files of the message. +// +// This method allows you to specify the files to be embedded in the message by providing a slice of File pointers. +// Embedded files, such as images or media, are typically used for inline content in HTML emails. +// +// Parameters: +// - files: A slice of pointers to File structures representing the embedded files to set for the message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) SetEmbeds(files []*File) { m.embeds = files } -// UnsetAllEmbeds unset the embeds of the message. +// UnsetAllEmbeds unsets the embedded files of the message. +// +// This method removes all embedded files from the message by setting the embeds to nil, effectively +// clearing any previously set embedded files. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) UnsetAllEmbeds() { m.embeds = nil } -// UnsetAllParts unset the embeds and attachments of the message. +// UnsetAllParts unsets the embeds and attachments of the message. +// +// This method removes all embedded files and attachments from the message by unsetting both the +// embeds and attachments, effectively clearing all previously set message parts. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) UnsetAllParts() { m.UnsetAllAttachments() m.UnsetAllEmbeds() } // SetBodyString sets the body of the message. +// +// This method sets the body of the message using the provided content type and string content. The body can +// be set as plain text, HTML, or other formats based on the specified content type. Optional part settings +// can be passed through PartOption to customize the message body further. +// +// Parameters: +// - contentType: The ContentType of the body (e.g., plain text, HTML). +// - content: The string content to set as the body of the message. +// - opts: Optional parameters for customizing the body part. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) SetBodyString(contentType ContentType, content string, opts ...PartOption) { buffer := bytes.NewBufferString(content) writeFunc := writeFuncFromBuffer(buffer) @@ -744,6 +1611,20 @@ func (m *Msg) SetBodyString(contentType ContentType, content string, opts ...Par } // SetBodyWriter sets the body of the message. +// +// This method sets the body of the message using a write function, allowing content to be written +// directly to the body. The content type determines the format (e.g., plain text, HTML). +// Optional part settings can be provided via PartOption to customize the body further. +// +// Parameters: +// - contentType: The ContentType of the body (e.g., plain text, HTML). +// - writeFunc: A function that writes content to an io.Writer and returns the number of bytes written +// and an error, if any. +// - opts: Optional parameters for customizing the body part. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) SetBodyWriter( contentType ContentType, writeFunc func(io.Writer) (int64, error), opts ...PartOption, @@ -753,8 +1634,24 @@ func (m *Msg) SetBodyWriter( m.parts = []*Part{p} } -// SetBodyHTMLTemplate sets the body of the message from a given html/template.Template pointer -// The content type will be set to text/html automatically +// SetBodyHTMLTemplate sets the body of the message from a given html/template.Template pointer. +// +// This method sets the body of the message using the provided HTML template and data. The content type +// will be set to "text/html" automatically. The method executes the template with the provided data +// and writes the output to the message body. If the template is nil or fails to execute, an error will +// be returned. +// +// Parameters: +// - tpl: A pointer to the html/template.Template to be used for the message body. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the body part. +// +// Returns: +// - An error if the template is nil or fails to execute, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) SetBodyHTMLTemplate(tpl *ht.Template, data interface{}, opts ...PartOption) error { if tpl == nil { return errors.New(errTplPointerNil) @@ -768,8 +1665,24 @@ func (m *Msg) SetBodyHTMLTemplate(tpl *ht.Template, data interface{}, opts ...Pa return nil } -// SetBodyTextTemplate sets the body of the message from a given text/template.Template pointer -// The content type will be set to text/plain automatically +// SetBodyTextTemplate sets the body of the message from a given text/template.Template pointer. +// +// This method sets the body of the message using the provided text template and data. The content type +// will be set to "text/plain" automatically. The method executes the template with the provided data +// and writes the output to the message body. If the template is nil or fails to execute, an error will +// be returned. +// +// Parameters: +// - tpl: A pointer to the text/template.Template to be used for the message body. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the body part. +// +// Returns: +// - An error if the template is nil or fails to execute, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) SetBodyTextTemplate(tpl *tt.Template, data interface{}, opts ...PartOption) error { if tpl == nil { return errors.New(errTplPointerNil) @@ -784,13 +1697,40 @@ func (m *Msg) SetBodyTextTemplate(tpl *tt.Template, data interface{}, opts ...Pa } // AddAlternativeString sets the alternative body of the message. +// +// This method adds an alternative representation of the message body using the specified content type +// and string content. This is typically used to provide both plain text and HTML versions of the email. +// Optional part settings can be provided via PartOption to further customize the message. +// +// Parameters: +// - contentType: The content type of the alternative body (e.g., plain text, HTML). +// - content: The string content to set as the alternative body. +// - opts: Optional parameters for customizing the alternative body part. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) AddAlternativeString(contentType ContentType, content string, opts ...PartOption) { buffer := bytes.NewBufferString(content) writeFunc := writeFuncFromBuffer(buffer) m.AddAlternativeWriter(contentType, writeFunc, opts...) } -// AddAlternativeWriter sets the body of the message. +// AddAlternativeWriter sets the alternative body of the message. +// +// This method adds an alternative representation of the message body using a write function, allowing +// content to be written directly to the body. This is typically used to provide different formats, such +// as plain text and HTML. Optional part settings can be provided via PartOption to customize the message part. +// +// Parameters: +// - contentType: The content type of the alternative body (e.g., plain text, HTML). +// - writeFunc: A function that writes content to an io.Writer and returns the number of bytes written and +// an error, if any. +// - opts: Optional parameters for customizing the alternative body part. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) AddAlternativeWriter( contentType ContentType, writeFunc func(io.Writer) (int64, error), opts ...PartOption, @@ -800,8 +1740,23 @@ func (m *Msg) AddAlternativeWriter( m.parts = append(m.parts, part) } -// AddAlternativeHTMLTemplate sets the alternative body of the message to a html/template.Template output -// The content type will be set to text/html automatically +// AddAlternativeHTMLTemplate sets the alternative body of the message to an html/template.Template output. +// +// The content type will be set to "text/html" automatically. This method executes the provided HTML template +// with the given data and adds the result as an alternative version of the message body. If the template +// is nil or fails to execute, an error will be returned. +// +// Parameters: +// - tpl: A pointer to the html/template.Template to be used for the alternative body. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the alternative body part. +// +// Returns: +// - An error if the template is nil or fails to execute, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) AddAlternativeHTMLTemplate(tpl *ht.Template, data interface{}, opts ...PartOption) error { if tpl == nil { return errors.New(errTplPointerNil) @@ -815,8 +1770,23 @@ func (m *Msg) AddAlternativeHTMLTemplate(tpl *ht.Template, data interface{}, opt return nil } -// AddAlternativeTextTemplate sets the alternative body of the message to a text/template.Template output -// The content type will be set to text/plain automatically +// AddAlternativeTextTemplate sets the alternative body of the message to a text/template.Template output. +// +// The content type will be set to "text/plain" automatically. This method executes the provided text template +// with the given data and adds the result as an alternative version of the message body. If the template +// is nil or fails to execute, an error will be returned. +// +// Parameters: +// - tpl: A pointer to the text/template.Template to be used for the alternative body. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the alternative body part. +// +// Returns: +// - An error if the template is nil or fails to execute, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) AddAlternativeTextTemplate(tpl *tt.Template, data interface{}, opts ...PartOption) error { if tpl == nil { return errors.New(errTplPointerNil) @@ -830,7 +1800,18 @@ func (m *Msg) AddAlternativeTextTemplate(tpl *tt.Template, data interface{}, opt return nil } -// AttachFile adds an attachment File to the Msg +// AttachFile adds an attachment File to the Msg. +// +// This method attaches a file to the message by specifying the file name. The file is retrieved from the +// filesystem and added to the list of attachments. Optional FileOption parameters can be provided to customize +// the attachment, such as setting its content type or encoding. +// +// Parameters: +// - name: The name of the file to be attached. +// - opts: Optional parameters for customizing the attachment. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachFile(name string, opts ...FileOption) { file := fileFromFS(name) if file == nil { @@ -839,12 +1820,22 @@ func (m *Msg) AttachFile(name string, opts ...FileOption) { m.attachments = m.appendFile(m.attachments, file, opts...) } -// AttachReader adds an attachment File via io.Reader to the Msg +// AttachReader adds an attachment File via io.Reader to the Msg. // -// CAVEAT: For AttachReader to work it has to read all data of the io.Reader -// into memory first, so it can seek through it. Using larger amounts of -// data on the io.Reader should be avoided. For such, it is recommended to -// either use AttachFile or AttachReadSeeker instead +// This method allows you to attach a file to the message using an io.Reader. It reads all data from the +// io.Reader into memory before attaching the file, which may not be suitable for large data sources. +// For larger files, it is recommended to use AttachFile or AttachReadSeeker instead. +// +// Parameters: +// - name: The name of the file to be attached. +// - reader: The io.Reader providing the file data to be attached. +// - opts: Optional parameters for customizing the attachment. +// +// Returns: +// - An error if the file could not be read from the io.Reader, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachReader(name string, reader io.Reader, opts ...FileOption) error { file, err := fileFromReader(name, reader) if err != nil { @@ -854,13 +1845,41 @@ func (m *Msg) AttachReader(name string, reader io.Reader, opts ...FileOption) er return nil } -// AttachReadSeeker adds an attachment File via io.ReadSeeker to the Msg +// AttachReadSeeker adds an attachment File via io.ReadSeeker to the Msg. +// +// This method allows you to attach a file to the message using an io.ReadSeeker, which is more efficient +// for larger files compared to AttachReader, as it allows for seeking through the data without needing +// to load the entire content into memory. +// +// Parameters: +// - name: The name of the file to be attached. +// - reader: The io.ReadSeeker providing the file data to be attached. +// - opts: Optional parameters for customizing the attachment. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachReadSeeker(name string, reader io.ReadSeeker, opts ...FileOption) { file := fileFromReadSeeker(name, reader) m.attachments = m.appendFile(m.attachments, file, opts...) } -// AttachHTMLTemplate adds the output of a html/template.Template pointer as File attachment to the Msg +// AttachHTMLTemplate adds the output of a html/template.Template pointer as a File attachment to the Msg. +// +// This method allows you to attach the rendered output of an HTML template as a file to the message. +// The template is executed with the provided data, and its output is attached as a file. If the template +// fails to execute, an error will be returned. +// +// Parameters: +// - name: The name of the file to be attached. +// - tpl: A pointer to the html/template.Template to be executed for the attachment. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the attachment. +// +// Returns: +// - An error if the template fails to execute or cannot be attached, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachHTMLTemplate( name string, tpl *ht.Template, data interface{}, opts ...FileOption, ) error { @@ -872,7 +1891,23 @@ func (m *Msg) AttachHTMLTemplate( return nil } -// AttachTextTemplate adds the output of a text/template.Template pointer as File attachment to the Msg +// AttachTextTemplate adds the output of a text/template.Template pointer as a File attachment to the Msg. +// +// This method allows you to attach the rendered output of a text template as a file to the message. +// The template is executed with the provided data, and its output is attached as a file. If the template +// fails to execute, an error will be returned. +// +// Parameters: +// - name: The name of the file to be attached. +// - tpl: A pointer to the text/template.Template to be executed for the attachment. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the attachment. +// +// Returns: +// - An error if the template fails to execute or cannot be attached, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachTextTemplate( name string, tpl *tt.Template, data interface{}, opts ...FileOption, ) error { @@ -884,7 +1919,22 @@ func (m *Msg) AttachTextTemplate( return nil } -// AttachFromEmbedFS adds an attachment File from an embed.FS to the Msg +// AttachFromEmbedFS adds an attachment File from an embed.FS to the Msg. +// +// This method allows you to attach a file from an embedded filesystem (embed.FS) to the message. +// The file is retrieved from the provided embed.FS and attached to the email. If the embedded filesystem +// is nil or the file cannot be retrieved, an error will be returned. +// +// Parameters: +// - name: The name of the file to be attached. +// - fs: A pointer to the embed.FS from which the file will be retrieved. +// - opts: Optional parameters for customizing the attachment. +// +// Returns: +// - An error if the embed.FS is nil or the file cannot be retrieved, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) AttachFromEmbedFS(name string, fs *embed.FS, opts ...FileOption) error { if fs == nil { return fmt.Errorf("embed.FS must not be nil") @@ -897,7 +1947,18 @@ func (m *Msg) AttachFromEmbedFS(name string, fs *embed.FS, opts ...FileOption) e return nil } -// EmbedFile adds an embedded File to the Msg +// EmbedFile adds an embedded File to the Msg. +// +// This method embeds a file from the filesystem directly into the email message. The embedded file, +// typically an image or media file, can be referenced within the email's content (such as inline in HTML). +// If the file is not found or cannot be loaded, it will not be added. +// +// Parameters: +// - name: The name of the file to be embedded. +// - opts: Optional parameters for customizing the embedded file. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedFile(name string, opts ...FileOption) { file := fileFromFS(name) if file == nil { @@ -906,12 +1967,22 @@ func (m *Msg) EmbedFile(name string, opts ...FileOption) { m.embeds = m.appendFile(m.embeds, file, opts...) } -// EmbedReader adds an embedded File from an io.Reader to the Msg +// EmbedReader adds an embedded File from an io.Reader to the Msg. // -// CAVEAT: For EmbedReader to work it has to read all data of the io.Reader -// into memory first, so it can seek through it. Using larger amounts of -// data on the io.Reader should be avoided. For such, it is recommended to -// either use EmbedFile or EmbedReadSeeker instead +// This method embeds a file into the email message by reading its content from an io.Reader. +// It reads all data into memory before embedding the file, which may not be efficient for large data sources. +// For larger files, it is recommended to use EmbedFile or EmbedReadSeeker instead. +// +// Parameters: +// - name: The name of the file to be embedded. +// - reader: The io.Reader providing the file data to be embedded. +// - opts: Optional parameters for customizing the embedded file. +// +// Returns: +// - An error if the file could not be read from the io.Reader, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedReader(name string, reader io.Reader, opts ...FileOption) error { file, err := fileFromReader(name, reader) if err != nil { @@ -921,13 +1992,41 @@ func (m *Msg) EmbedReader(name string, reader io.Reader, opts ...FileOption) err return nil } -// EmbedReadSeeker adds an embedded File from an io.ReadSeeker to the Msg +// EmbedReadSeeker adds an embedded File from an io.ReadSeeker to the Msg. +// +// This method embeds a file into the email message by reading its content from an io.ReadSeeker. +// Using io.ReadSeeker allows for more efficient handling of large files since it can seek through the data +// without loading the entire content into memory. +// +// Parameters: +// - name: The name of the file to be embedded. +// - reader: The io.ReadSeeker providing the file data to be embedded. +// - opts: Optional parameters for customizing the embedded file. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedReadSeeker(name string, reader io.ReadSeeker, opts ...FileOption) { file := fileFromReadSeeker(name, reader) m.embeds = m.appendFile(m.embeds, file, opts...) } -// EmbedHTMLTemplate adds the output of a html/template.Template pointer as embedded File to the Msg +// EmbedHTMLTemplate adds the output of a html/template.Template pointer as an embedded File to the Msg. +// +// This method embeds the rendered output of an HTML template into the email message. The template is +// executed with the provided data, and its output is embedded as a file. If the template fails to execute, +// an error will be returned. +// +// Parameters: +// - name: The name of the embedded file. +// - tpl: A pointer to the html/template.Template to be executed for the embedded content. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the embedded file. +// +// Returns: +// - An error if the template fails to execute or cannot be embedded, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedHTMLTemplate( name string, tpl *ht.Template, data interface{}, opts ...FileOption, ) error { @@ -939,7 +2038,23 @@ func (m *Msg) EmbedHTMLTemplate( return nil } -// EmbedTextTemplate adds the output of a text/template.Template pointer as embedded File to the Msg +// EmbedTextTemplate adds the output of a text/template.Template pointer as an embedded File to the Msg. +// +// This method embeds the rendered output of a text template into the email message. The template is +// executed with the provided data, and its output is embedded as a file. If the template fails to execute, +// an error will be returned. +// +// Parameters: +// - name: The name of the embedded file. +// - tpl: A pointer to the text/template.Template to be executed for the embedded content. +// - data: The data to populate the template. +// - opts: Optional parameters for customizing the embedded file. +// +// Returns: +// - An error if the template fails to execute or cannot be embedded, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedTextTemplate( name string, tpl *tt.Template, data interface{}, opts ...FileOption, ) error { @@ -951,7 +2066,21 @@ func (m *Msg) EmbedTextTemplate( return nil } -// EmbedFromEmbedFS adds an embedded File from an embed.FS to the Msg +// EmbedFromEmbedFS adds an embedded File from an embed.FS to the Msg. +// +// This method embeds a file from an embedded filesystem (embed.FS) into the email message. If the +// embedded filesystem is nil or the file cannot be retrieved, an error will be returned. +// +// Parameters: +// - name: The name of the file to be embedded. +// - fs: A pointer to the embed.FS from which the file will be retrieved. +// - opts: Optional parameters for customizing the embedded file. +// +// Returns: +// - An error if the embed.FS is nil or the file cannot be retrieved, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func (m *Msg) EmbedFromEmbedFS(name string, fs *embed.FS, opts ...FileOption) error { if fs == nil { return fmt.Errorf("embed.FS must not be nil") @@ -964,8 +2093,14 @@ func (m *Msg) EmbedFromEmbedFS(name string, fs *embed.FS, opts ...FileOption) er return nil } -// Reset resets all headers, body parts and attachments/embeds of the Msg -// It leaves already set encodings, charsets, boundaries, etc. as is +// Reset resets all headers, body parts, attachments, and embeds of the Msg. +// +// This method clears all address headers, attachments, embeds, generic headers, and body parts of the message. +// However, it preserves the existing encoding, charset, boundary, and other message-level settings. +// Use this method to reset the message content while keeping certain configurations intact. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) Reset() { m.addrHeader = make(map[AddrHeader][]*mail.Address) m.attachments = nil @@ -974,7 +2109,17 @@ func (m *Msg) Reset() { m.parts = nil } -// ApplyMiddlewares apply the list of middlewares to a Msg +// ApplyMiddlewares applies the list of middlewares to a Msg. +// +// This method sequentially applies each middleware function in the list to the message (in FIFO order). +// The middleware functions can modify the message, such as adding headers or altering its content. +// The message is passed through each middleware in order, and the modified message is returned. +// +// Parameters: +// - msg: The Msg object to which the middlewares will be applied. +// +// Returns: +// - The modified Msg after all middleware functions have been applied. func (m *Msg) applyMiddlewares(msg *Msg) *Msg { for _, middleware := range m.middlewares { msg = middleware.Handle(msg) @@ -1014,7 +2159,21 @@ func (m *Msg) createSignaturePart(encoding Encoding, contentType ContentType, ch return signaturePart, nil } -// WriteTo writes the formated Msg into a give io.Writer and satisfies the io.WriteTo interface +// WriteTo writes the formatted Msg into the given io.Writer and satisfies the io.WriterTo interface. +// +// This method writes the email message, including its headers, body, and attachments, to the provided +// io.Writer. It applies any middlewares to the message before writing it. The total number of bytes +// written and any error encountered during the writing process are returned. +// +// Parameters: +// - writer: The io.Writer to which the formatted message will be written. +// +// Returns: +// - The total number of bytes written. +// - An error if any occurred during the writing process, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) WriteTo(writer io.Writer) (int64, error) { mw := &msgWriter{writer: writer, charset: m.charset, encoder: m.encoder} msg := m.applyMiddlewares(m) @@ -1032,8 +2191,23 @@ func (m *Msg) WriteTo(writer io.Writer) (int64, error) { return mw.bytesWritten, mw.err } -// WriteToSkipMiddleware writes the formated Msg into a give io.Writer and satisfies -// the io.WriteTo interface but will skip the given Middleware +// WriteToSkipMiddleware writes the formatted Msg into the given io.Writer, but skips the specified +// middleware type. +// +// This method writes the email message to the provided io.Writer after applying all middlewares, +// except for the specified middleware type, which will be skipped. It temporarily removes the +// middleware of the given type, writes the message, and then restores the original middleware list. +// +// Parameters: +// - writer: The io.Writer to which the formatted message will be written. +// - middleWareType: The MiddlewareType that should be skipped during the writing process. +// +// Returns: +// - The total number of bytes written. +// - An error if any occurred during the writing process, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) WriteToSkipMiddleware(writer io.Writer, middleWareType MiddlewareType) (int64, error) { var origMiddlewares, middlewares []Middleware origMiddlewares = m.middlewares @@ -1050,30 +2224,39 @@ func (m *Msg) WriteToSkipMiddleware(writer io.Writer, middleWareType MiddlewareT return mw.bytesWritten, mw.err } -// Write is an alias method to WriteTo due to compatibility reasons +// Write is an alias method to WriteTo for compatibility reasons. +// +// This method provides a backward-compatible way to write the formatted Msg to the provided io.Writer +// by calling the WriteTo method. It writes the email message, including headers, body, and attachments, +// to the io.Writer and returns the number of bytes written and any error encountered. +// +// Parameters: +// - writer: The io.Writer to which the formatted message will be written. +// +// Returns: +// - The total number of bytes written. +// - An error if any occurred during the writing process, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) Write(writer io.Writer) (int64, error) { return m.WriteTo(writer) } -// appendFile adds a File to the Msg (as attachment or embed) -func (m *Msg) appendFile(files []*File, file *File, opts ...FileOption) []*File { - // Override defaults with optionally provided FileOption functions - for _, opt := range opts { - if opt == nil { - continue - } - opt(file) - } - - if files == nil { - return []*File{file} - } - - return append(files, file) -} - -// WriteToFile stores the Msg as file on disk. It will try to create the given filename -// Already existing files will be overwritten +// WriteToFile stores the Msg as a file on disk. It will try to create the given filename, +// and if the file already exists, it will be overwritten. +// +// This method writes the email message, including its headers, body, and attachments, to a file on disk. +// If the file cannot be created or an error occurs during writing, an error is returned. +// +// Parameters: +// - name: The name of the file to be created or overwritten. +// +// Returns: +// - An error if the file cannot be created or if writing to the file fails, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) WriteToFile(name string) error { file, err := os.Create(name) if err != nil { @@ -1087,22 +2270,58 @@ func (m *Msg) WriteToFile(name string) error { return file.Close() } -// WriteToSendmail returns WriteToSendmailWithCommand with a default sendmail path +// WriteToSendmail returns WriteToSendmailWithCommand with a default sendmail path. +// +// This method sends the email message using the default sendmail path. It calls WriteToSendmailWithCommand +// using the standard SendmailPath. If sending via sendmail fails, an error is returned. +// +// Returns: +// - An error if sending the message via sendmail fails, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5321 func (m *Msg) WriteToSendmail() error { return m.WriteToSendmailWithCommand(SendmailPath) } // WriteToSendmailWithCommand returns WriteToSendmailWithContext with a default timeout -// of 5 seconds and a given sendmail path +// of 5 seconds and a given sendmail path. +// +// This method sends the email message using the provided sendmail path, with a default timeout of 5 seconds. +// It creates a context with the specified timeout and then calls WriteToSendmailWithContext to send the message. +// +// Parameters: +// - sendmailPath: The path to the sendmail executable to be used for sending the message. +// +// Returns: +// - An error if sending the message via sendmail fails, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5321 func (m *Msg) WriteToSendmailWithCommand(sendmailPath string) error { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() return m.WriteToSendmailWithContext(ctx, sendmailPath) } -// WriteToSendmailWithContext opens an pipe to the local sendmail binary and tries to send the -// mail though that. It takes a context.Context, the path to the sendmail binary and additional -// arguments for the sendmail binary as parameters +// WriteToSendmailWithContext opens a pipe to the local sendmail binary and tries to send the +// email through it. It takes a context.Context, the path to the sendmail binary, and additional +// arguments for the sendmail binary as parameters. +// +// This method establishes a pipe to the sendmail executable using the provided context and arguments. +// It writes the email message to the sendmail process via STDIN. If any errors occur during the +// communication with the sendmail binary, they will be captured and returned. +// +// Parameters: +// - ctx: The context to control the timeout and cancellation of the sendmail process. +// - sendmailPath: The path to the sendmail executable. +// - args: Additional arguments for the sendmail binary. +// +// Returns: +// - An error if sending the message via sendmail fails, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5321 func (m *Msg) WriteToSendmailWithContext(ctx context.Context, sendmailPath string, args ...string) error { cmdCtx := exec.CommandContext(ctx, sendmailPath) cmdCtx.Args = append(cmdCtx.Args, "-oi", "-t") @@ -1155,10 +2374,19 @@ func (m *Msg) WriteToSendmailWithContext(ctx context.Context, sendmailPath strin // NewReader returns a Reader type that satisfies the io.Reader interface. // -// IMPORTANT: when creating a new Reader, the current state of the Msg is taken, as -// basis for the Reader. If you perform changes on Msg after creating the Reader, these -// changes will not be reflected in the Reader. You will have to use Msg.UpdateReader -// first to update the Reader's buffer with the current Msg content +// This method creates a new Reader for the Msg, capturing the current state of the message. +// Any subsequent changes made to the Msg after creating the Reader will not be reflected in the Reader's buffer. +// To reflect these changes in the Reader, you must call Msg.UpdateReader to update the Reader's content with +// the current state of the Msg. +// +// Returns: +// - A pointer to a Reader, which allows the Msg to be read as a stream of bytes. +// +// IMPORTANT: Any changes made to the Msg after creating the Reader will not be reflected in the Reader unless +// Msg.UpdateReader is called. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) NewReader() *Reader { reader := &Reader{} buffer := bytes.Buffer{} @@ -1170,8 +2398,17 @@ func (m *Msg) NewReader() *Reader { return reader } -// UpdateReader will update a Reader with the content of the given Msg and reset the -// Reader position to the start +// UpdateReader updates a Reader with the current content of the Msg and resets the +// Reader's position to the start. +// +// This method rewrites the content of the provided Reader to reflect any changes made to the Msg. +// It resets the Reader's position to the beginning and updates the buffer with the latest message content. +// +// Parameters: +// - reader: A pointer to the Reader that will be updated with the Msg's current content. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func (m *Msg) UpdateReader(reader *Reader) { buffer := bytes.Buffer{} _, err := m.Write(&buffer) @@ -1180,14 +2417,27 @@ func (m *Msg) UpdateReader(reader *Reader) { reader.err = err } -// HasSendError returns true if the Msg experienced an error during the message delivery and the -// sendError field of the Msg is not nil +// HasSendError returns true if the Msg experienced an error during message delivery +// and the sendError field of the Msg is not nil. +// +// This method checks whether the message has encountered a delivery error by verifying if the +// sendError field is populated. +// +// Returns: +// - A boolean value indicating whether a send error occurred (true if an error is present). func (m *Msg) HasSendError() bool { return m.sendError != nil } -// SendErrorIsTemp returns true if the Msg experienced an error during the message delivery and the -// corresponding error was of temporary nature and should be retried later +// SendErrorIsTemp returns true if the Msg experienced a delivery error, and the corresponding +// error was of a temporary nature, meaning it can be retried later. +// +// This method checks whether the encountered sendError is a temporary error that can be retried. +// It uses the errors.As function to determine if the error is of type SendError and checks if +// the error is marked as temporary. +// +// Returns: +// - A boolean value indicating whether the send error is temporary (true if the error is temporary). func (m *Msg) SendErrorIsTemp() bool { var err *SendError if errors.As(m.sendError, &err) && err != nil { @@ -1196,18 +2446,104 @@ func (m *Msg) SendErrorIsTemp() bool { return false } -// SendError returns the sendError field of the Msg +// SendError returns the sendError field of the Msg. +// +// This method retrieves the error that occurred during the message delivery process, if any. +// It returns the sendError field, which holds the error encountered during sending. +// +// Returns: +// - The error encountered during message delivery, or nil if no error occurred. func (m *Msg) SendError() error { return m.sendError } +// addAddr adds an additional address to the given addrHeader of the Msg. +// +// This method appends an email address to the specified address header (such as "To", "Cc", or "Bcc") +// without overwriting existing addresses. It first collects the current addresses in the header, then +// adds the new address and updates the header. +// +// Parameters: +// - header: The AddrHeader (e.g., HeaderTo, HeaderCc) to which the address will be added. +// - addr: The email address to add to the specified header. +// +// Returns: +// - An error if the address cannot be added, otherwise nil. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 +func (m *Msg) addAddr(header AddrHeader, addr string) error { + var addresses []string + for _, address := range m.addrHeader[header] { + addresses = append(addresses, address.String()) + } + addresses = append(addresses, addr) + return m.SetAddrHeader(header, addresses...) +} + +// appendFile adds a File to the Msg, either as an attachment or an embed. +// +// This method appends a File to the list of files (attachments or embeds) for the message. It applies +// optional FileOption functions to customize the file properties before adding it. If no files are +// already present, a new list is created. +// +// Parameters: +// - files: The current list of files (either attachments or embeds). +// - file: The File to be added. +// - opts: Optional FileOption functions to customize the file. +// +// Returns: +// - A slice of File pointers representing the updated list of files. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 +func (m *Msg) appendFile(files []*File, file *File, opts ...FileOption) []*File { + // Override defaults with optionally provided FileOption functions + for _, opt := range opts { + if opt == nil { + continue + } + opt(file) + } + + if files == nil { + return []*File{file} + } + + return append(files, file) +} + // encodeString encodes a string based on the configured message encoder and the corresponding -// charset for the Msg +// charset for the Msg. +// +// This method encodes the provided string using the message's charset and encoder settings. +// The encoding ensures that the string is properly formatted according to the message's +// character encoding (e.g., UTF-8, ISO-8859-1). +// +// Parameters: +// - str: The string to be encoded. +// +// Returns: +// - The encoded string based on the message's charset and encoder. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2047 func (m *Msg) encodeString(str string) string { return m.encoder.Encode(string(m.charset), str) } -// hasAlt returns true if the Msg has more than one part +// hasAlt returns true if the Msg has more than one part. +// +// This method checks whether the message contains more than one part, indicating that +// the message has alternative content (e.g., both plain text and HTML parts). It ignores +// any parts marked as deleted and returns true only if more than one valid part exists +// and no PGP type is set. +// +// Returns: +// - A boolean value indicating whether the message has multiple parts (true if more than one part exists). +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) hasAlt() bool { count := 0 for _, part := range m.parts { @@ -1218,7 +2554,17 @@ func (m *Msg) hasAlt() bool { return count > 1 && m.pgptype == 0 && !m.hasSMime() } -// hasMixed returns true if the Msg has mixed parts +// hasMixed returns true if the Msg has mixed parts. +// +// This method checks whether the message contains mixed content, such as attachments along with +// message parts (e.g., text or HTML). A message is considered to have mixed parts if there are both +// attachments and message parts, or if there are multiple attachments. +// +// Returns: +// - A boolean value indicating whether the message has mixed parts. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2046#section-5.1.3 func (m *Msg) hasMixed() bool { return m.pgptype == 0 && ((len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1) } @@ -1228,17 +2574,51 @@ func (m *Msg) hasSMime() bool { return m.sMime != nil } -// hasRelated returns true if the Msg has related parts +// hasRelated returns true if the Msg has related parts. +// +// This method checks whether the message contains related parts, such as inline embedded files +// (e.g., images) that are referenced within the message body. A message is considered to have +// related parts if there are both message parts and embedded files, or if there are multiple embedded files. +// +// Returns: +// - A boolean value indicating whether the message has related parts. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2387 func (m *Msg) hasRelated() bool { return m.pgptype == 0 && ((len(m.parts) > 0 && len(m.embeds) > 0) || len(m.embeds) > 1) } -// hasPGPType returns true if the Msg should be treated as PGP encoded message +// hasPGPType returns true if the Msg should be treated as a PGP-encoded message. +// +// This method checks whether the message is configured to be treated as a PGP-encoded message by examining +// the pgptype field. If the PGP type is set to a value greater than 0, the message is considered PGP-encoded. +// +// Returns: +// - A boolean value indicating whether the message is PGP-encoded. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc4880 func (m *Msg) hasPGPType() bool { return m.pgptype > 0 } -// newPart returns a new Part for the Msg +// newPart returns a new Part for the Msg. +// +// This method creates a new Part for the message with the specified content type, +// using the message's current charset and encoding settings. Optional PartOption +// functions can be applied to customize the Part further. +// +// Parameters: +// - contentType: The content type for the new Part (e.g., text/plain, text/html). +// - opts: Optional PartOption functions to customize the Part. +// +// Returns: +// - A pointer to the newly created Part structure. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 +// - https://datatracker.ietf.org/doc/html/rfc2046 func (m *Msg) newPart(contentType ContentType, opts ...PartOption) *Part { p := &Part{ contentType: contentType, @@ -1257,13 +2637,26 @@ func (m *Msg) newPart(contentType ContentType, opts ...PartOption) *Part { return p } -// setEncoder creates a new mime.WordEncoder based on the encoding setting of the message +// setEncoder creates a new mime.WordEncoder based on the encoding setting of the message. +// +// This method sets the message's encoder by creating a new mime.WordEncoder that matches the +// current encoding setting (e.g., quoted-printable or base64). The encoder is used to encode +// message headers and body content appropriately. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2047 func (m *Msg) setEncoder() { m.encoder = getEncoder(m.encoding) } -// checkUserAgent checks if a useragent/x-mailer is set and if not will set a default -// version string +// checkUserAgent checks if a User-Agent or X-Mailer header is set, and if not, sets a default version string. +// +// This method ensures that the message includes a User-Agent and X-Mailer header, unless the noDefaultUserAgent +// flag is set. If neither of these headers is present, a default User-Agent string with the current library +// version is added. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.7 func (m *Msg) checkUserAgent() { if m.noDefaultUserAgent { return @@ -1276,7 +2669,16 @@ func (m *Msg) checkUserAgent() { } } -// addDefaultHeader sets some default headers, if they haven't been set before +// addDefaultHeader sets default headers if they haven't been set before. +// +// This method ensures that essential headers such as "Date", "Message-ID", and "MIME-Version" are set +// in the message. If these headers are not already present, they will be set to default values. +// The "Date" and "Message-ID" headers are generated, and the "MIME-Version" is set to the message's current setting. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.1 (Date) +// - https://datatracker.ietf.org/doc/html/rfc5322#section-3.6.4 (Message-ID) +// - https://datatracker.ietf.org/doc/html/rfc2045#section-4 (MIME-Version) func (m *Msg) addDefaultHeader() { if _, ok := m.genHeader[HeaderDate]; !ok { m.SetDate() @@ -1287,7 +2689,22 @@ func (m *Msg) addDefaultHeader() { m.SetGenHeader(HeaderMIMEVersion, string(m.mimever)) } -// fileFromEmbedFS returns a File pointer from a given file in the provided embed.FS +// fileFromEmbedFS returns a File pointer from a given file in the provided embed.FS. +// +// This method retrieves a file from the embedded filesystem (embed.FS) and returns a File structure +// that can be used as an attachment or embed in the email message. The file's content is read when +// writing to an io.Writer, and the file is identified by its base name. +// +// Parameters: +// - name: The name of the file to retrieve from the embedded filesystem. +// - fs: A pointer to the embed.FS from which the file will be opened. +// +// Returns: +// - A pointer to the File structure representing the embedded file. +// - An error if the file cannot be opened or read from the embedded filesystem. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func fileFromEmbedFS(name string, fs *embed.FS) (*File, error) { _, err := fs.Open(name) if err != nil { @@ -1311,7 +2728,21 @@ func fileFromEmbedFS(name string, fs *embed.FS) (*File, error) { }, nil } -// fileFromFS returns a File pointer from a given file in the system's file system +// fileFromFS returns a File pointer from a given file in the system's file system. +// +// This method retrieves a file from the system's file system and returns a File structure +// that can be used as an attachment or embed in the email message. The file is identified +// by its base name, and its content is read when writing to an io.Writer. +// +// Parameters: +// - name: The name of the file to retrieve from the system's file system. +// +// Returns: +// - A pointer to the File structure representing the file from the system's file system. +// - Nil if the file does not exist or cannot be accessed. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func fileFromFS(name string) *File { _, err := os.Stat(name) if err != nil { @@ -1336,7 +2767,22 @@ func fileFromFS(name string) *File { } } -// fileFromReader returns a File pointer from a given io.Reader +// fileFromReader returns a File pointer from a given io.Reader. +// +// This method reads all data from the provided io.Reader and creates a File structure +// that can be used as an attachment or embed in the email message. The file's content +// is stored in memory and written to an io.Writer when needed. +// +// Parameters: +// - name: The name of the file to be represented by the reader's content. +// - reader: The io.Reader from which the file content will be read. +// +// Returns: +// - A pointer to the File structure representing the content of the io.Reader. +// - An error if the content cannot be read from the io.Reader. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func fileFromReader(name string, reader io.Reader) (*File, error) { d, err := io.ReadAll(reader) if err != nil { @@ -1357,7 +2803,21 @@ func fileFromReader(name string, reader io.Reader) (*File, error) { }, nil } -// fileFromReadSeeker returns a File pointer from a given io.ReadSeeker +// fileFromReadSeeker returns a File pointer from a given io.ReadSeeker. +// +// This method creates a File structure from an io.ReadSeeker, allowing efficient handling of file content +// by seeking and reading from the source without fully loading it into memory. The content is written +// to an io.Writer when needed, and the reader's position is reset to the start after writing. +// +// Parameters: +// - name: The name of the file to be represented by the io.ReadSeeker. +// - reader: The io.ReadSeeker from which the file content will be read. +// +// Returns: +// - A pointer to the File structure representing the content of the io.ReadSeeker. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func fileFromReadSeeker(name string, reader io.ReadSeeker) *File { return &File{ Name: name, @@ -1373,7 +2833,23 @@ func fileFromReadSeeker(name string, reader io.ReadSeeker) *File { } } -// fileFromHTMLTemplate returns a File pointer form a given html/template.Template +// fileFromHTMLTemplate returns a File pointer from a given html/template.Template. +// +// This method executes the provided HTML template with the given data and creates a File structure +// representing the output. The rendered template content is stored in a buffer and then processed +// as a file attachment or embed. +// +// Parameters: +// - name: The name of the file to be created from the template output. +// - tpl: A pointer to the html/template.Template to be executed. +// - data: The data to populate the template. +// +// Returns: +// - A pointer to the File structure representing the rendered template. +// - An error if the template is nil or if it fails to execute. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func fileFromHTMLTemplate(name string, tpl *ht.Template, data interface{}) (*File, error) { if tpl == nil { return nil, errors.New(errTplPointerNil) @@ -1385,7 +2861,23 @@ func fileFromHTMLTemplate(name string, tpl *ht.Template, data interface{}) (*Fil return fileFromReader(name, &buffer) } -// fileFromTextTemplate returns a File pointer form a given text/template.Template +// fileFromTextTemplate returns a File pointer from a given text/template.Template. +// +// This method executes the provided text template with the given data and creates a File structure +// representing the output. The rendered template content is stored in a buffer and then processed +// as a file attachment or embed. +// +// Parameters: +// - name: The name of the file to be created from the template output. +// - tpl: A pointer to the text/template.Template to be executed. +// - data: The data to populate the template. +// +// Returns: +// - A pointer to the File structure representing the rendered template. +// - An error if the template is nil or if it fails to execute. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2183 func fileFromTextTemplate(name string, tpl *tt.Template, data interface{}) (*File, error) { if tpl == nil { return nil, errors.New(errTplPointerNil) @@ -1397,7 +2889,19 @@ func fileFromTextTemplate(name string, tpl *tt.Template, data interface{}) (*Fil return fileFromReader(name, &buffer) } -// getEncoder creates a new mime.WordEncoder based on the encoding setting of the message +// getEncoder creates a new mime.WordEncoder based on the encoding setting of the message. +// +// This function returns a mime.WordEncoder based on the specified encoding (e.g., quoted-printable or base64). +// The encoder is used for encoding message headers and body content according to the chosen encoding standard. +// +// Parameters: +// - enc: The Encoding type for the message (e.g., EncodingQP for quoted-printable or EncodingB64 for base64). +// +// Returns: +// - A mime.WordEncoder based on the encoding setting. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2047 func getEncoder(enc Encoding) mime.WordEncoder { switch enc { case EncodingQP: @@ -1409,8 +2913,21 @@ func getEncoder(enc Encoding) mime.WordEncoder { } } -// writeFuncFromBuffer is a common method to convert a byte buffer into a writeFunc as -// often required by this library +// writeFuncFromBuffer converts a byte buffer into a writeFunc, which is commonly required by go-mail. +// +// This function wraps a byte buffer into a write function that can be used to write the buffer's content +// to an io.Writer. It returns a function that writes the buffer's content to the given writer and returns +// the number of bytes written and any error that occurred during writing. +// +// Parameters: +// - buffer: A pointer to the bytes.Buffer containing the data to be written. +// +// Returns: +// - A function that writes the buffer's content to an io.Writer, returning the number of bytes written +// and any error encountered during the write operation. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc5322 func writeFuncFromBuffer(buffer *bytes.Buffer) func(io.Writer) (int64, error) { writeFunc := func(w io.Writer) (int64, error) { numBytes, err := w.Write(buffer.Bytes()) diff --git a/msg_nowin_test.go b/msg_nowin_test.go index 820be24..6cde71a 100644 --- a/msg_nowin_test.go +++ b/msg_nowin_test.go @@ -61,3 +61,25 @@ func TestMsg_WriteToSendmail(t *testing.T) { t.Errorf("WriteToSendmail failed: %s", err) } } + +func TestMsg_WriteToTempFileFailed(t *testing.T) { + m := NewMsg() + _ = m.From("Toni Tester ") + _ = m.To("Ellenor Tester ") + m.SetBodyString(TypeTextPlain, "This is a test") + + curTmpDir := os.Getenv("TMPDIR") + defer func() { + if err := os.Setenv("TMPDIR", curTmpDir); err != nil { + t.Errorf("failed to set TMPDIR environment variable: %s", err) + } + }() + + if err := os.Setenv("TMPDIR", "/invalid/directory/that/does/not/exist"); err != nil { + t.Errorf("failed to set TMPDIR environment variable: %s", err) + } + _, err := m.WriteToTempFile() + if err == nil { + t.Errorf("WriteToTempFile() did not fail as expected") + } +} diff --git a/msg_test.go b/msg_test.go index 192af62..ffd1357 100644 --- a/msg_test.go +++ b/msg_test.go @@ -786,13 +786,11 @@ func TestMsg_SetMessageIDWithValue(t *testing.T) { // TestMsg_SetMessageIDRandomness tests the randomness of Msg.SetMessageID methods func TestMsg_SetMessageIDRandomness(t *testing.T) { var mids []string - for i := 0; i < 100; i++ { - m := NewMsg() + m := NewMsg() + for i := 0; i < 50_000; i++ { m.SetMessageID() - mid := m.GetGenHeader(HeaderMessageID) - if len(mid) > 0 { - mids = append(mids, mid[0]) - } + mid := m.GetMessageID() + mids = append(mids, mid) } c := make(map[string]int) for i := range mids { @@ -805,6 +803,21 @@ func TestMsg_SetMessageIDRandomness(t *testing.T) { } } +func TestMsg_GetMessageID(t *testing.T) { + expected := "this.is.a.message.id" + msg := NewMsg() + msg.SetMessageIDWithValue(expected) + val := msg.GetMessageID() + if !strings.EqualFold(val, fmt.Sprintf("<%s>", expected)) { + t.Errorf("GetMessageID() failed. Expected: %s, got: %s", fmt.Sprintf("<%s>", expected), val) + } + msg.genHeader[HeaderMessageID] = nil + val = msg.GetMessageID() + if val != "" { + t.Errorf("GetMessageID() failed. Expected empty string, got: %s", val) + } +} + // TestMsg_FromFormat tests the FromFormat and EnvelopeFrom methods for the Msg object func TestMsg_FromFormat(t *testing.T) { tests := []struct { diff --git a/msg_totmpfile.go b/msg_totmpfile.go index 44c427d..b7d2ada 100644 --- a/msg_totmpfile.go +++ b/msg_totmpfile.go @@ -12,8 +12,14 @@ import ( "os" ) -// WriteToTempFile will create a temporary file and output the Msg to this file -// The method will return the filename of the temporary file +// WriteToTempFile creates a temporary file and writes the Msg content to this file. +// +// This method generates a temporary file with a ".eml" extension, writes the Msg to it, and returns the +// filename of the created temporary file. +// +// Returns: +// - A string representing the filename of the temporary file. +// - An error if the file creation or writing process fails. func (m *Msg) WriteToTempFile() (string, error) { f, err := os.CreateTemp("", "go-mail_*.eml") if err != nil { diff --git a/msg_totmpfile_116.go b/msg_totmpfile_116.go index 4611106..bb93411 100644 --- a/msg_totmpfile_116.go +++ b/msg_totmpfile_116.go @@ -12,8 +12,14 @@ import ( "io/ioutil" ) -// WriteToTempFile will create a temporary file and output the Msg to this file -// The method will return the filename of the temporary file +// WriteToTempFile creates a temporary file and writes the Msg content to this file. +// +// This method generates a temporary file with a ".eml" extension, writes the Msg to it, and returns the +// filename of the created temporary file. +// +// Returns: +// - A string representing the filename of the temporary file. +// - An error if the file creation or writing process fails. func (m *Msg) WriteToTempFile() (string, error) { f, err := ioutil.TempFile("", "go-mail_*.eml") if err != nil { diff --git a/msgwriter.go b/msgwriter.go index 8182168..331138c 100644 --- a/msgwriter.go +++ b/msgwriter.go @@ -18,22 +18,39 @@ import ( "strings" ) -// MaxHeaderLength defines the maximum line length for a mail header -// RFC 2047 suggests 76 characters -const MaxHeaderLength = 76 +const ( + // MaxHeaderLength defines the maximum line length for a mail header. + // + // This constant follows the recommendation of RFC 2047, which suggests a maximum length of 76 characters. + // + // References: + // - https://datatracker.ietf.org/doc/html/rfc2047 + MaxHeaderLength = 76 -// MaxBodyLength defines the maximum line length for the mail body -// RFC 2047 suggests 76 characters -const MaxBodyLength = 76 + // MaxBodyLength defines the maximum line length for the mail body. + // + // This constant follows the recommendation of RFC 2047, which suggests a maximum length of 76 characters. + // + // References: + // - https://datatracker.ietf.org/doc/html/rfc2047 + MaxBodyLength = 76 -// SingleNewLine represents a new line that can be used by the msgWriter to issue a carriage return -const SingleNewLine = "\r\n" + // SingleNewLine represents a single newline character sequence ("\r\n"). + // + // This constant can be used by the msgWriter to issue a carriage return when writing mail content. + SingleNewLine = "\r\n" -// DoubleNewLine represents a double new line that can be used by the msgWriter to -// indicate a new segement of the mail -const DoubleNewLine = "\r\n\r\n" + // DoubleNewLine represents a double newline character sequence ("\r\n\r\n"). + // + // This constant can be used by the msgWriter to indicate a new segment of the mail when writing mail content. + DoubleNewLine = "\r\n\r\n" +) -// msgWriter handles the I/O to the io.WriteCloser of the SMTP client +// msgWriter handles the I/O operations for writing to the io.WriteCloser of the SMTP client. +// +// This struct keeps track of the number of bytes written, the character set used, and the depth of the +// current multipart section. It also handles encoding, error tracking, and managing multipart and part +// writers for constructing the email message body. type msgWriter struct { bytesWritten int64 charset Charset @@ -45,7 +62,18 @@ type msgWriter struct { writer io.Writer } -// Write implements the io.Writer interface for msgWriter +// Write implements the io.Writer interface for msgWriter. +// +// This method writes the provided payload to the underlying writer. It keeps track of the number of bytes +// written and handles any errors encountered during the writing process. If a previous error exists, it +// prevents further writing and returns the error. +// +// Parameters: +// - payload: A byte slice containing the data to be written. +// +// Returns: +// - The number of bytes successfully written. +// - An error if the writing process fails, or if a previous error was encountered. func (mw *msgWriter) Write(payload []byte) (int, error) { if mw.err != nil { return 0, fmt.Errorf("failed to write due to previous error: %w", mw.err) @@ -57,7 +85,19 @@ func (mw *msgWriter) Write(payload []byte) (int, error) { return n, mw.err } -// writeMsg formats the message and sends it to its io.Writer +// writeMsg formats the message and writes it to the msgWriter's io.Writer. +// +// This method handles the process of writing the message headers and body content, including handling +// multipart structures (e.g., mixed, related, alternative), PGP types, and attachments/embeds. It sets the +// required headers (e.g., "From", "To", "Cc") and iterates over the message parts, writing them to the +// output writer. +// +// Parameters: +// - msg: A pointer to the Msg struct containing the message data and headers to be written. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2045 (Multipurpose Internet Mail Extensions - MIME) +// - https://datatracker.ietf.org/doc/html/rfc5322 (Internet Message Format) func (mw *msgWriter) writeMsg(msg *Msg) { msg.addDefaultHeader() msg.checkUserAgent() @@ -144,7 +184,13 @@ func (mw *msgWriter) writeMsg(msg *Msg) { } } -// writeGenHeader writes out all generic headers to the msgWriter +// writeGenHeader writes out all generic headers to the msgWriter. +// +// This function extracts all generic headers from the provided Msg object, sorts them, and writes them +// to the msgWriter in alphabetical order. +// +// Parameters: +// - msg: The Msg object containing the headers to be written. func (mw *msgWriter) writeGenHeader(msg *Msg) { keys := make([]string, 0, len(msg.genHeader)) for key := range msg.genHeader { @@ -156,14 +202,32 @@ func (mw *msgWriter) writeGenHeader(msg *Msg) { } } -// writePreformatedHeader writes out all preformated generic headers to the msgWriter +// writePreformattedGenHeader writes out all preformatted generic headers to the msgWriter. +// +// This function iterates over all preformatted generic headers from the provided Msg object and writes +// them to the msgWriter in the format "key: value" followed by a newline. +// +// Parameters: +// - msg: The Msg object containing the preformatted headers to be written. func (mw *msgWriter) writePreformattedGenHeader(msg *Msg) { for key, val := range msg.preformHeader { mw.writeString(fmt.Sprintf("%s: %s%s", key, val, SingleNewLine)) } } -// startMP writes a multipart beginning +// startMP writes a multipart beginning. +// +// This function initializes a multipart writer for the msgWriter using the specified MIME type and +// boundary. It sets the Content-Type header to indicate the multipart type and writes the boundary +// information. If a boundary is provided, it is set explicitly; otherwise, a default boundary is +// generated. It also handles writing a new part when nested multipart structures are used. +// +// Parameters: +// - mimeType: The MIME type of the multipart content (e.g., "mixed", "alternative"). +// - boundary: The boundary string separating different parts of the multipart message. +// +// References: +// - https://datatracker.ietf.org/doc/html/rfc2046 func (mw *msgWriter) startMP(mimeType MIMEType, boundary string) { multiPartWriter := multipart.NewWriter(mw) if boundary != "" { @@ -183,7 +247,10 @@ func (mw *msgWriter) startMP(mimeType MIMEType, boundary string) { mw.depth++ } -// stopMP closes the multipart +// stopMP closes the multipart. +// +// This function closes the current multipart writer if there is an active multipart structure. +// It decreases the depth level of multipart nesting. func (mw *msgWriter) stopMP() { if mw.depth > 0 { mw.err = mw.multiPartWriter[mw.depth-1].Close() @@ -191,7 +258,17 @@ func (mw *msgWriter) stopMP() { } } -// addFiles adds the attachments/embeds file content to the mail body +// addFiles adds the attachments/embeds file content to the mail body. +// +// This function iterates through the list of files, setting necessary headers for each file, +// including Content-Type, Content-Transfer-Encoding, Content-Disposition, and Content-ID +// (if the file is an embed). It determines the appropriate MIME type for each file based on +// its extension or the provided ContentType. It writes file headers and file content +// to the mail body using the appropriate encoding. +// +// Parameters: +// - files: A slice of File objects to be added to the mail body. +// - isAttachment: A boolean indicating whether the files are attachments (true) or embeds (false). func (mw *msgWriter) addFiles(files []*File, isAttachment bool) { for _, file := range files { encoding := EncodingB64 @@ -250,12 +327,29 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) { } } -// newPart creates a new MIME multipart io.Writer and sets the partwriter to it +// newPart creates a new MIME multipart io.Writer and sets the partWriter to it. +// +// This function creates a new MIME part using the provided header information and assigns it +// to the partWriter. It interacts with the current multipart writer at the specified depth +// to create the part. +// +// Parameters: +// - header: A map containing the header fields and their corresponding values for the new part. func (mw *msgWriter) newPart(header map[string][]string) { mw.partWriter, mw.err = mw.multiPartWriter[mw.depth-1].CreatePart(header) } -// writePart writes the corresponding part to the Msg body +// writePart writes the corresponding part to the Msg body. +// +// This function writes a MIME part to the message body, setting the appropriate headers such +// as Content-Type and Content-Transfer-Encoding. It determines the charset for the part, +// either using the part's own charset or a fallback charset if none is specified. If the part +// is at the top level (depth 0), headers are written directly. For nested parts, it creates +// a new MIME part with the provided headers. +// +// Parameters: +// - part: The Part object containing the data to be written. +// - charset: The Charset used as a fallback if the part does not specify one. func (mw *msgWriter) writePart(part *Part, charset Charset) { partCharset := part.charset if partCharset.String() == "" { @@ -285,7 +379,14 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) { mw.writeBody(part.writeFunc, part.encoding, part.smime) } -// writeString writes a string into the msgWriter's io.Writer interface +// writeString writes a string into the msgWriter's io.Writer interface. +// +// This function writes the given string to the msgWriter's underlying writer. It checks for +// existing errors before performing the write operation. It also tracks the number of bytes +// written and updates the bytesWritten field accordingly. +// +// Parameters: +// - s: The string to be written. func (mw *msgWriter) writeString(s string) { if mw.err != nil { return @@ -295,7 +396,16 @@ func (mw *msgWriter) writeString(s string) { mw.bytesWritten += int64(n) } -// writeHeader writes a header into the msgWriter's io.Writer +// writeHeader writes a header into the msgWriter's io.Writer. +// +// This function writes a header key and its associated values to the msgWriter. It ensures +// proper formatting of long headers by inserting line breaks as needed. The header values +// are joined and split into words to ensure compliance with the maximum header length +// (MaxHeaderLength). After processing the header, it is written to the underlying writer. +// +// Parameters: +// - key: The Header key to be written. +// - values: A variadic parameter representing the values associated with the header. func (mw *msgWriter) writeHeader(key Header, values ...string) { buffer := strings.Builder{} charLength := MaxHeaderLength - 2 @@ -330,7 +440,18 @@ func (mw *msgWriter) writeHeader(key Header, values ...string) { mw.writeString("\r\n") } -// writeBody writes an io.Reader into an io.Writer using provided Encoding +// writeBody writes an io.Reader into an io.Writer using the provided Encoding. +// +// This function writes data from an io.Reader to the underlying writer using a specified +// encoding (quoted-printable, base64, or no encoding). It handles encoding of the content +// and manages writing the encoded data to the appropriate writer, depending on the depth +// (whether the data is part of a multipart structure or not). It also tracks the number +// of bytes written and manages any errors encountered during the process. +// +// Parameters: +// - writeFunc: A function that writes the body content to the given io.Writer. +// - encoding: The encoding type to use when writing the content (e.g., base64, quoted-printable). +// - singingWithSMime: Whether the msg should be signed with S/MIME or not. func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encoding Encoding, singingWithSMime bool) { var writer io.Writer var encodedWriter io.WriteCloser diff --git a/part.go b/part.go index 035b342..153f281 100644 --- a/part.go +++ b/part.go @@ -12,7 +12,11 @@ import ( // PartOption returns a function that can be used for grouping Part options type PartOption func(*Part) -// Part is a part of the Msg +// Part is a part of the Msg. +// +// This struct represents a single part of a multipart message. Each part has a content type, +// charset, optional description, encoding, and a function to write its content to an io.Writer. +// It also includes a flag to mark the part as deleted. type Part struct { contentType ContentType charset Charset @@ -23,7 +27,14 @@ type Part struct { smime bool } -// GetContent executes the WriteFunc of the Part and returns the content as byte slice +// GetContent executes the WriteFunc of the Part and returns the content as a byte slice. +// +// This function runs the part's writeFunc to write its content into a buffer and then returns +// the content as a byte slice. If an error occurs during the writing process, it is returned. +// +// Returns: +// - A byte slice containing the part's content. +// - An error if the writeFunc encounters an issue. func (p *Part) GetContent() ([]byte, error) { var b bytes.Buffer if _, err := p.writeFunc(&b); err != nil { @@ -32,27 +43,54 @@ func (p *Part) GetContent() ([]byte, error) { return b.Bytes(), nil } -// GetCharset returns the currently set Charset of the Part +// GetCharset returns the currently set Charset of the Part. +// +// This function returns the Charset that is currently set for the Part. +// +// Returns: +// - The Charset of the Part. func (p *Part) GetCharset() Charset { return p.charset } -// GetContentType returns the currently set ContentType of the Part +// GetContentType returns the currently set ContentType of the Part. +// +// This function returns the ContentType that is currently set for the Part. +// +// Returns: +// - The ContentType of the Part. func (p *Part) GetContentType() ContentType { return p.contentType } -// GetEncoding returns the currently set Encoding of the Part +// GetEncoding returns the currently set Encoding of the Part. +// +// This function returns the Encoding that is currently set for the Part. +// +// Returns: +// - The Encoding of the Part. func (p *Part) GetEncoding() Encoding { return p.encoding } -// GetWriteFunc returns the currently set WriterFunc of the Part +// GetWriteFunc returns the currently set WriteFunc of the Part. +// +// This function returns the WriteFunc that is currently set for the Part, which writes +// the part's content to an io.Writer. +// +// Returns: +// - The WriteFunc of the Part, which is a function that takes an io.Writer and returns +// the number of bytes written and an error (if any). func (p *Part) GetWriteFunc() func(io.Writer) (int64, error) { return p.writeFunc } -// GetDescription returns the currently set Content-Description of the Part +// GetDescription returns the currently set Content-Description of the Part. +// +// This function returns the Content-Description that is currently set for the Part. +// +// Returns: +// - The Content-Description of the Part as a string. func (p *Part) GetDescription() string { return p.description } @@ -62,63 +100,126 @@ func (p *Part) IsSMimeSigned() bool { return p.smime } -// SetContent overrides the content of the Part with the given string +// SetContent overrides the content of the Part with the given string. +// +// This function sets the content of the Part by creating a new writeFunc that writes the +// provided string content to an io.Writer. +// +// Parameters: +// - content: The string that will replace the current content of the Part. func (p *Part) SetContent(content string) { buffer := bytes.NewBufferString(content) p.writeFunc = writeFuncFromBuffer(buffer) } -// SetContentType overrides the ContentType of the Part +// SetContentType overrides the ContentType of the Part. +// +// This function sets a new ContentType for the Part, replacing the existing one. +// +// Parameters: +// - contentType: The new ContentType to be set for the Part. func (p *Part) SetContentType(contentType ContentType) { p.contentType = contentType } -// SetCharset overrides the Charset of the Part +// SetCharset overrides the Charset of the Part. +// +// This function sets a new Charset for the Part, replacing the existing one. +// +// Parameters: +// - charset: The new Charset to be set for the Part. func (p *Part) SetCharset(charset Charset) { p.charset = charset } -// SetEncoding creates a new mime.WordEncoder based on the encoding setting of the message +// SetEncoding creates a new mime.WordEncoder based on the encoding setting of the message. +// +// This function sets a new Encoding for the Part, replacing the existing one. +// +// Parameters: +// - encoding: The new Encoding to be set for the Part. func (p *Part) SetEncoding(encoding Encoding) { p.encoding = encoding } -// SetDescription overrides the Content-Description of the Part +// SetDescription overrides the Content-Description of the Part. +// +// This function sets a new Content-Description for the Part, replacing the existing one. +// +// Parameters: +// - description: The new Content-Description to be set for the Part. func (p *Part) SetDescription(description string) { p.description = description } + // SetIsSMimeSigned sets the flag for signing the Part with S/MIME func (p *Part) SetIsSMimeSigned(smime bool) { p.smime = smime } -// SetWriteFunc overrides the WriteFunc of the Part +// SetWriteFunc overrides the WriteFunc of the Part. +// +// This function sets a new WriteFunc for the Part, replacing the existing one. The WriteFunc +// is responsible for writing the Part's content to an io.Writer. +// +// Parameters: +// - writeFunc: A function that writes the Part's content to an io.Writer and returns +// the number of bytes written and an error (if any). func (p *Part) SetWriteFunc(writeFunc func(io.Writer) (int64, error)) { p.writeFunc = writeFunc } -// Delete removes the current part from the parts list of the Msg by setting the -// isDeleted flag to true. The msgWriter will skip it then +// Delete removes the current part from the parts list of the Msg by setting the isDeleted flag to true. +// +// This function marks the Part as deleted by setting the isDeleted flag to true. The msgWriter +// will skip over this Part during processing. func (p *Part) Delete() { p.isDeleted = true } -// WithPartCharset overrides the default Part charset +// WithPartCharset overrides the default Part charset. +// +// This function returns a PartOption that allows the charset of a Part to be overridden +// with the specified Charset. +// +// Parameters: +// - charset: The Charset to be set for the Part. +// +// Returns: +// - A PartOption function that sets the Part's charset. func WithPartCharset(charset Charset) PartOption { return func(p *Part) { p.charset = charset } } -// WithPartEncoding overrides the default Part encoding +// WithPartEncoding overrides the default Part encoding. +// +// This function returns a PartOption that allows the encoding of a Part to be overridden +// with the specified Encoding. +// +// Parameters: +// - encoding: The Encoding to be set for the Part. +// +// Returns: +// - A PartOption function that sets the Part's encoding. func WithPartEncoding(encoding Encoding) PartOption { return func(p *Part) { p.encoding = encoding } } -// WithPartContentDescription overrides the default Part Content-Description +// WithPartContentDescription overrides the default Part Content-Description. +// +// This function returns a PartOption that allows the Content-Description of a Part +// to be overridden with the specified description. +// +// Parameters: +// - description: The Content-Description to be set for the Part. +// +// Returns: +// - A PartOption function that sets the Part's Content-Description. func WithPartContentDescription(description string) PartOption { return func(p *Part) { p.description = description diff --git a/random.go b/random.go index 831b118..e987f00 100644 --- a/random.go +++ b/random.go @@ -7,23 +7,41 @@ package mail import ( "crypto/rand" "encoding/binary" - "fmt" - "math/big" "strings" ) // Range of characters for the secure string generation -const cr = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" +const cr = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-" // Bitmask sizes for the string generators (based on 93 chars total) +// +// These constants define bitmask-related values used for efficient random string generation. +// The bitmask operates over 66 possible characters, and the constants help determine the +// number of bits and indices used in the process. const ( - letterIdxBits = 7 // 7 bits to represent a letter index - letterIdxMask = 1< tc.max { - t.Errorf("random number generation failed: %d is bigger than given value %d", rn, tc.max) - } - }) +func BenchmarkGenerator_RandomStringSecure(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _, err := randomStringSecure(22) + if err != nil { + b.Errorf("RandomStringFromCharRange() failed: %s", err) + } } } diff --git a/reader.go b/reader.go index 3c44f4c..e635e3a 100644 --- a/reader.go +++ b/reader.go @@ -8,19 +8,41 @@ import ( "io" ) -// Reader is a type that implements the io.Reader interface for a Msg +// Reader is a type that implements the io.Reader interface for a Msg. +// +// This struct represents a reader that reads from a byte slice buffer. It keeps track of the +// current read position (offset) and any initialization error. The buffer holds the data to be +// read from the message. type Reader struct { buffer []byte // contents are the bytes buffer[offset : len(buffer)] offset int // read at &buffer[offset], write at &buffer[len(buffer)] err error // initialization error } -// Error returns an error if the Reader err field is not nil +// Error returns an error if the Reader err field is not nil. +// +// This function checks the Reader's err field and returns it if it is not nil. If no error +// occurred during initialization, it returns nil. +// +// Returns: +// - The error stored in the err field, or nil if no error is present. func (r *Reader) Error() error { return r.err } -// Read reads the length of p of the Msg buffer to satisfy the io.Reader interface +// Read reads the content of the Msg buffer into the provided payload to satisfy the io.Reader interface. +// +// This function reads data from the Reader's buffer into the provided byte slice (payload). +// It checks for errors or an empty buffer and resets the Reader if necessary. If no data is available, +// it returns io.EOF. Otherwise, it copies the content from the buffer into the payload and updates +// the read offset. +// +// Parameters: +// - payload: A byte slice where the data will be copied. +// +// Returns: +// - n: The number of bytes copied into the payload. +// - err: An error if any issues occurred during the read operation or io.EOF if the buffer is empty. func (r *Reader) Read(payload []byte) (n int, err error) { if r.err != nil { return 0, r.err @@ -37,12 +59,20 @@ func (r *Reader) Read(payload []byte) (n int, err error) { return n, err } -// Reset resets the Reader buffer to be empty, but it retains the underlying storage -// for use by future writes. +// Reset resets the Reader buffer to be empty, but it retains the underlying storage for future use. +// +// This function clears the Reader's buffer by setting its length to 0 and resets the read offset +// to the beginning. The underlying storage is retained, allowing future writes to reuse the buffer. func (r *Reader) Reset() { r.buffer = r.buffer[:0] r.offset = 0 } // empty reports whether the unread portion of the Reader buffer is empty. +// +// This function checks if the unread portion of the Reader's buffer is empty by comparing +// the length of the buffer to the current read offset. +// +// Returns: +// - true if the unread portion is empty, false otherwise. func (r *Reader) empty() bool { return len(r.buffer) <= r.offset } diff --git a/senderror.go b/senderror.go index dfe7502..1943e28 100644 --- a/senderror.go +++ b/senderror.go @@ -54,18 +54,32 @@ const ( ErrAmbiguous ) -// SendError is an error wrapper for delivery errors of the Msg +// SendError is an error wrapper for delivery errors of the Msg. +// +// This struct represents an error that occurs during the delivery of a message. It holds +// details about the affected message, a list of errors, the recipient list, and whether +// the error is temporary or permanent. It also includes a reason code for the error. type SendError struct { - Reason SendErrReason - isTemp bool - errlist []error - rcpt []string + affectedMsg *Msg + errlist []error + isTemp bool + rcpt []string + Reason SendErrReason } // SendErrReason represents a comparable reason on why the delivery failed type SendErrReason int -// Error implements the error interface for the SendError type +// Error implements the error interface for the SendError type. +// +// This function returns a detailed error message string for the SendError, including the +// reason for failure, list of errors, affected recipients, and the message ID of the +// affected message (if available). If the reason is unknown (greater than 10), it returns +// "unknown reason". The error message is built dynamically based on the content of the +// error list, recipient list, and message ID. +// +// Returns: +// - A string representing the error message. func (e *SendError) Error() string { if e.Reason > 10 { return "unknown reason" @@ -92,10 +106,25 @@ func (e *SendError) Error() string { } } } + if e.affectedMsg != nil && e.affectedMsg.GetMessageID() != "" { + errMessage.WriteString(", affected message ID: ") + errMessage.WriteString(e.affectedMsg.GetMessageID()) + } + return errMessage.String() } -// Is implements the errors.Is functionality and compares the SendErrReason +// Is implements the errors.Is functionality and compares the SendErrReason. +// +// This function allows for comparison between two errors by checking if the provided +// error matches the SendError type and, if so, compares the SendErrReason and the +// temporary status (isTemp) of both errors. +// +// Parameters: +// - errType: The error to compare against the current SendError. +// +// Returns: +// - true if the errors have the same reason and temporary status, false otherwise. func (e *SendError) Is(errType error) bool { var t *SendError if errors.As(errType, &t) && t != nil { @@ -104,7 +133,13 @@ func (e *SendError) Is(errType error) bool { return false } -// IsTemp returns true if the delivery error is of temporary nature and can be retried +// IsTemp returns true if the delivery error is of a temporary nature and can be retried. +// +// This function checks whether the SendError indicates a temporary error, which suggests +// that the delivery can be retried. If the SendError is nil, it returns false. +// +// Returns: +// - true if the error is temporary, false otherwise. func (e *SendError) IsTemp() bool { if e == nil { return false @@ -112,7 +147,42 @@ func (e *SendError) IsTemp() bool { return e.isTemp } -// String implements the Stringer interface for the SendErrReason +// MessageID returns the message ID of the affected Msg that caused the error. +// +// This function retrieves the message ID of the Msg associated with the SendError. +// If no message ID was set or if the SendError or Msg is nil, it returns an empty string. +// +// Returns: +// - The message ID as a string, or an empty string if no ID is available. +func (e *SendError) MessageID() string { + if e == nil || e.affectedMsg == nil { + return "" + } + return e.affectedMsg.GetMessageID() +} + +// Msg returns the pointer to the affected message that caused the error. +// +// This function retrieves the Msg associated with the SendError. If the SendError or +// the affectedMsg is nil, it returns nil. +// +// Returns: +// - A pointer to the Msg that caused the error, or nil if not available. +func (e *SendError) Msg() *Msg { + if e == nil || e.affectedMsg == nil { + return nil + } + return e.affectedMsg +} + +// String satisfies the fmt.Stringer interface for the SendErrReason type. +// +// This function converts the SendErrReason into a human-readable string representation based +// on the error type. If the error reason does not match any predefined case, it returns +// "unknown reason". +// +// Returns: +// - A string representation of the SendErrReason. func (r SendErrReason) String() string { switch r { case ErrGetSender: @@ -141,8 +211,16 @@ func (r SendErrReason) String() string { return "unknown reason" } -// isTempError checks the given SMTP error and returns true if the given error is of temporary nature -// and should be retried +// isTempError checks if the given SMTP error is of a temporary nature and should be retried. +// +// This function inspects the error message and returns true if the first character of the +// error message is '4', indicating a temporary SMTP error that can be retried. +// +// Parameters: +// - err: The error to check. +// +// Returns: +// - true if the error is temporary, false otherwise. func isTempError(err error) bool { return err.Error()[0] == '4' } diff --git a/senderror_test.go b/senderror_test.go index e83df00..e04b7ee 100644 --- a/senderror_test.go +++ b/senderror_test.go @@ -83,7 +83,96 @@ func TestSendError_IsTemp(t *testing.T) { } } +func TestSendError_IsTempNil(t *testing.T) { + var se *SendError + if se.IsTemp() { + t.Error("expected false on nil-senderror") + } +} + +func TestSendError_MessageID(t *testing.T) { + var se *SendError + err := returnSendError(ErrAmbiguous, false) + if !errors.As(err, &se) { + t.Errorf("error mismatch, expected error to be of type *SendError") + return + } + if errors.As(err, &se) { + if se.MessageID() == "" { + t.Errorf("sendError expected message-id, but got empty string") + } + if !strings.EqualFold(se.MessageID(), "") { + t.Errorf("sendError message-id expected: %s, but got: %s", "", + se.MessageID()) + } + } +} + +func TestSendError_MessageIDNil(t *testing.T) { + var se *SendError + if se.MessageID() != "" { + t.Error("expected empty string on nil-senderror") + } +} + +func TestSendError_Msg(t *testing.T) { + var se *SendError + err := returnSendError(ErrAmbiguous, false) + if !errors.As(err, &se) { + t.Errorf("error mismatch, expected error to be of type *SendError") + return + } + if errors.As(err, &se) { + if se.Msg() == nil { + t.Errorf("sendError expected msg pointer, but got nil") + } + from := se.Msg().GetFromString() + if len(from) == 0 { + t.Errorf("sendError expected msg from, but got empty string") + return + } + if !strings.EqualFold(from[0], "") { + t.Errorf("sendError message from expected: %s, but got: %s", "", + from[0]) + } + } +} + +func TestSendError_MsgNil(t *testing.T) { + var se *SendError + if se.Msg() != nil { + t.Error("expected nil on nil-senderror") + } +} + +func TestSendError_IsFail(t *testing.T) { + err1 := returnSendError(ErrAmbiguous, false) + err2 := returnSendError(ErrSMTPMailFrom, false) + if errors.Is(err1, err2) { + t.Errorf("error mismatch, ErrAmbiguous should not be equal to ErrSMTPMailFrom") + } +} + +func TestSendError_ErrorMulti(t *testing.T) { + expected := `ambiguous reason, check Msg.SendError for message specific reasons, ` + + `affected recipient(s): , ` + err := &SendError{ + Reason: ErrAmbiguous, isTemp: false, affectedMsg: nil, + rcpt: []string{"", ""}, + } + if err.Error() != expected { + t.Errorf("error mismatch, expected: %s, got: %s", expected, err.Error()) + } +} + // returnSendError is a helper method to retunr a SendError with a specific reason func returnSendError(r SendErrReason, t bool) error { - return &SendError{Reason: r, isTemp: t} + message := NewMsg() + _ = message.From("toni.tester@domain.tld") + _ = message.To("tina.tester@domain.tld") + message.Subject("This is the subject") + message.SetBodyString(TypeTextPlain, "This is the message body") + message.SetMessageIDWithValue("this.is.a.message.id") + + return &SendError{Reason: r, isTemp: t, affectedMsg: message} } diff --git a/smtp/auth.go b/smtp/auth.go index 30948e1..a62e74d 100644 --- a/smtp/auth.go +++ b/smtp/auth.go @@ -13,6 +13,19 @@ package smtp +import "errors" + +var ( + // ErrUnencrypted is an error indicating that the connection is not encrypted. + ErrUnencrypted = errors.New("unencrypted connection") + // ErrUnexpectedServerChallange is an error indicating that the server issued an unexpected challenge. + ErrUnexpectedServerChallange = errors.New("unexpected server challenge") + // ErrUnexpectedServerResponse is an error indicating that the server issued an unexpected response. + ErrUnexpectedServerResponse = errors.New("unexpected server response") + // ErrWrongHostname is an error indicating that the provided hostname does not match the expected value. + ErrWrongHostname = errors.New("wrong host name") +) + // Auth is implemented by an SMTP authentication mechanism. type Auth interface { // Start begins an authentication with a server. diff --git a/smtp/auth_login.go b/smtp/auth_login.go index aa80223..847ad62 100644 --- a/smtp/auth_login.go +++ b/smtp/auth_login.go @@ -1,11 +1,10 @@ -// SPDX-FileCopyrightText: Copyright (c) 2022-2023 The go-mail Authors +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors // // SPDX-License-Identifier: MIT package smtp import ( - "errors" "fmt" ) @@ -13,53 +12,35 @@ import ( type loginAuth struct { username, password string host string + respStep uint8 } -const ( - // LoginXUsernameChallenge represents the Username Challenge response sent by the SMTP server per the AUTH LOGIN - // extension. - // - // See: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-xlogin/. - LoginXUsernameChallenge = "Username:" - LoginXUsernameLowerChallenge = "username:" - - // LoginXPasswordChallenge represents the Password Challenge response sent by the SMTP server per the AUTH LOGIN - // extension. - // - // See: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-xlogin/. - LoginXPasswordChallenge = "Password:" - LoginXPasswordLowerChallenge = "password:" - - // LoginXDraftUsernameChallenge represents the Username Challenge response sent by the SMTP server per the IETF - // draft AUTH LOGIN extension. It should be noted this extension is an expired draft which was never formally - // published and was deprecated in favor of the AUTH PLAIN extension. - // - // See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00. - LoginXDraftUsernameChallenge = "User Name\x00" - - // LoginXDraftPasswordChallenge represents the Password Challenge response sent by the SMTP server per the IETF - // draft AUTH LOGIN extension. It should be noted this extension is an expired draft which was never formally - // published and was deprecated in favor of the AUTH PLAIN extension. - // - // See: https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00. - LoginXDraftPasswordChallenge = "Password\x00" -) - // LoginAuth returns an [Auth] that implements the LOGIN authentication // mechanism as it is used by MS Outlook. The Auth works similar to PLAIN // but instead of sending all in one response, the login is handled within // 3 steps: -// - Sending AUTH LOGIN (server responds with "Username:") -// - Sending the username (server responds with "Password:") +// - Sending AUTH LOGIN (server might responds with "Username:") +// - Sending the username (server might responds with "Password:") // - Sending the password (server authenticates) +// This is the common approach as specified by Microsoft in their MS-XLOGIN spec. +// See: https://msopenspecs.azureedge.net/files/MS-XLOGIN/%5bMS-XLOGIN%5d.pdf +// Yet, there is also an old IETF draft for SMTP AUTH LOGIN that states for clients: +// "The contents of both challenges SHOULD be ignored.". +// 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 +// of this mechanism (sending "Username:", "Username", "username", "User name", etc.) +// we follow the IETF-Draft and ignore any server challange to allow compatiblity +// with most mail servers/providers. // // LoginAuth will only send the credentials if the connection is using TLS // or is connected to localhost. Otherwise authentication will fail with an // error, without sending the credentials. func LoginAuth(username, password, host string) Auth { - return &loginAuth{username, password, host} + return &loginAuth{username, password, host, 0} } +// Start begins the SMTP authentication process by validating server's TLS status and hostname. +// Returns "LOGIN" on success. func (a *loginAuth) Start(server *ServerInfo) (string, []byte, error) { // Must have TLS, or else localhost server. // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. @@ -67,23 +48,28 @@ func (a *loginAuth) Start(server *ServerInfo) (string, []byte, error) { // That might just be the attacker saying // "it's ok, you can trust me with your password." if !server.TLS && !isLocalhost(server.Name) { - return "", nil, errors.New("unencrypted connection") + return "", nil, ErrUnencrypted } if server.Name != a.host { - return "", nil, errors.New("wrong host name") + return "", nil, ErrWrongHostname } + a.respStep = 0 return "LOGIN", nil, nil } +// Next processes responses from the server during the SMTP authentication exchange, sending the +// username and password. func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { if more { - switch string(fromServer) { - case LoginXUsernameChallenge, LoginXUsernameLowerChallenge, LoginXDraftUsernameChallenge: + switch a.respStep { + case 0: + a.respStep++ return []byte(a.username), nil - case LoginXPasswordChallenge, LoginXPasswordLowerChallenge, LoginXDraftPasswordChallenge: + case 1: + a.respStep++ return []byte(a.password), nil default: - return nil, fmt.Errorf("unexpected server response: %s", string(fromServer)) + return nil, fmt.Errorf("%w: %s", ErrUnexpectedServerResponse, string(fromServer)) } } return nil, nil diff --git a/smtp/auth_plain.go b/smtp/auth_plain.go index 2430c96..e6e0ad9 100644 --- a/smtp/auth_plain.go +++ b/smtp/auth_plain.go @@ -13,10 +13,6 @@ package smtp -import ( - "errors" -) - // plainAuth is the type that satisfies the Auth interface for the "SMTP PLAIN" auth type plainAuth struct { identity, username, password string @@ -42,10 +38,10 @@ func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { // That might just be the attacker saying // "it's ok, you can trust me with your password." if !server.TLS && !isLocalhost(server.Name) { - return "", nil, errors.New("unencrypted connection") + return "", nil, ErrUnencrypted } if server.Name != a.host { - return "", nil, errors.New("wrong host name") + return "", nil, ErrWrongHostname } resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password) return "PLAIN", resp, nil @@ -54,7 +50,7 @@ func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { func (a *plainAuth) Next(_ []byte, more bool) ([]byte, error) { if more { // We've already sent everything. - return nil, errors.New("unexpected server challenge") + return nil, ErrUnexpectedServerChallange } return nil, nil } diff --git a/smtp/auth_scram.go b/smtp/auth_scram.go new file mode 100644 index 0000000..a21aef5 --- /dev/null +++ b/smtp/auth_scram.go @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package smtp + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "hash" + "io" + "strconv" + "strings" + + "golang.org/x/crypto/pbkdf2" + "golang.org/x/text/secure/precis" +) + +// scramAuth represents a SCRAM (Salted Challenge Response Authentication Mechanism) client and +// satisfies the smtp.Auth interface. +type scramAuth struct { + username, password, algorithm string + firstBareMsg, nonce, saltedPwd, authMessage []byte + iterations int + h func() hash.Hash + isPlus bool + tlsConnState *tls.ConnectionState + bindData []byte +} + +// ScramSHA1Auth creates and returns a new SCRAM-SHA-1 authentication mechanism with the given +// username and password. +func ScramSHA1Auth(username, password string) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-1", + h: sha1.New, + } +} + +// ScramSHA256Auth creates and returns a new SCRAM-SHA-256 authentication mechanism with the given +// username and password. +func ScramSHA256Auth(username, password string) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-256", + h: sha256.New, + } +} + +// ScramSHA1PlusAuth returns an Auth instance configured for SCRAM-SHA-1-PLUS authentication with +// the provided username, password, and TLS connection state. +func ScramSHA1PlusAuth(username, password string, tlsConnState *tls.ConnectionState) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-1-PLUS", + h: sha1.New, + isPlus: true, + tlsConnState: tlsConnState, + } +} + +// ScramSHA256PlusAuth returns an Auth instance configured for SCRAM-SHA-256-PLUS authentication with +// the provided username, password, and TLS connection state. +func ScramSHA256PlusAuth(username, password string, tlsConnState *tls.ConnectionState) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-256-PLUS", + h: sha256.New, + isPlus: true, + tlsConnState: tlsConnState, + } +} + +// Start initializes the SCRAM authentication process and returns the selected algorithm, nil data, and no error. +func (a *scramAuth) Start(_ *ServerInfo) (string, []byte, error) { + return a.algorithm, nil, nil +} + +// Next processes the server's challenge and returns the client's response for SCRAM authentication. +func (a *scramAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + if len(fromServer) == 0 { + a.reset() + return a.initialClientMessage() + } + switch { + case bytes.HasPrefix(fromServer, []byte("r=")): + resp, err := a.handleServerFirstResponse(fromServer) + if err != nil { + a.reset() + return nil, err + } + return resp, nil + case bytes.HasPrefix(fromServer, []byte("v=")): + resp, err := a.handleServerValidationMessage(fromServer) + if err != nil { + a.reset() + return nil, err + } + return resp, nil + default: + a.reset() + return nil, fmt.Errorf("%w: %s", ErrUnexpectedServerResponse, string(fromServer)) + } + } + return nil, nil +} + +// reset clears all authentication-related properties in the scramAuth instance, effectively resetting its state. +func (a *scramAuth) reset() { + a.nonce = nil + a.firstBareMsg = nil + a.saltedPwd = nil + a.authMessage = nil + a.iterations = 0 +} + +// initialClientMessage generates the initial message for SCRAM authentication, including a nonce and +// optional channel binding. +func (a *scramAuth) initialClientMessage() ([]byte, error) { + username, err := a.normalizeUsername() + if err != nil { + return nil, fmt.Errorf("username normalization failed: %w", err) + } + + nonceBuffer := make([]byte, 24) + if _, err := io.ReadFull(rand.Reader, nonceBuffer); err != nil { + return nil, fmt.Errorf("unable to generate client secret: %w", err) + } + a.nonce = make([]byte, base64.StdEncoding.EncodedLen(len(nonceBuffer))) + base64.StdEncoding.Encode(a.nonce, nonceBuffer) + + a.firstBareMsg = []byte("n=" + username + ",r=" + string(a.nonce)) + returnBytes := []byte("n,," + string(a.firstBareMsg)) + + // SCRAM-SHA-X-PLUS auth requires channel binding + if a.isPlus { + if a.tlsConnState == nil { + return nil, errors.New("tls connection state is required for SCRAM-SHA-X-PLUS") + } + bindType := "tls-unique" + connState := a.tlsConnState + bindData := connState.TLSUnique + + // crypto/tl: no tls-unique channel binding value for this tls connection, possibly due to missing + // extended master key support and/or resumed connection + // RFC9266:122 tls-unique not defined for tls 1.3 and later + if bindData == nil || connState.Version >= tls.VersionTLS13 { + bindType = "tls-exporter" + bindData, err = connState.ExportKeyingMaterial("EXPORTER-Channel-Binding", []byte{}, 32) + if err != nil { + return nil, fmt.Errorf("unable to export keying material: %w", err) + } + } + bindData = []byte("p=" + bindType + ",," + string(bindData)) + a.bindData = make([]byte, base64.StdEncoding.EncodedLen(len(bindData))) + base64.StdEncoding.Encode(a.bindData, bindData) + returnBytes = []byte("p=" + bindType + ",," + string(a.firstBareMsg)) + } + + return returnBytes, nil +} + +// handleServerFirstResponse processes the first response from the server in SCRAM authentication. +func (a *scramAuth) handleServerFirstResponse(fromServer []byte) ([]byte, error) { + parts := bytes.Split(fromServer, []byte(",")) + if len(parts) < 3 { + return nil, errors.New("not enough fields in the first server response") + } + if !bytes.HasPrefix(parts[0], []byte("r=")) { + return nil, errors.New("first part of the server response does not start with r=") + } + if !bytes.HasPrefix(parts[1], []byte("s=")) { + return nil, errors.New("second part of the server response does not start with s=") + } + if !bytes.HasPrefix(parts[2], []byte("i=")) { + return nil, errors.New("third part of the server response does not start with i=") + } + + combinedNonce := parts[0][2:] + if len(a.nonce) == 0 || !bytes.HasPrefix(combinedNonce, a.nonce) { + return nil, errors.New("server nonce does not start with our nonce") + } + a.nonce = combinedNonce + + encodedSalt := parts[1][2:] + salt := make([]byte, base64.StdEncoding.DecodedLen(len(encodedSalt))) + n, err := base64.StdEncoding.Decode(salt, encodedSalt) + if err != nil { + return nil, fmt.Errorf("invalid encoded salt: %w", err) + } + salt = salt[:n] + + iterations, err := strconv.Atoi(string(parts[2][2:])) + if err != nil { + return nil, fmt.Errorf("invalid iterations: %w", err) + } + a.iterations = iterations + + password, err := a.normalizeString(a.password) + if err != nil { + return nil, fmt.Errorf("unable to normalize password: %w", err) + } + + a.saltedPwd = pbkdf2.Key([]byte(password), salt, a.iterations, a.h().Size(), a.h) + + msgWithoutProof := []byte("c=biws,r=" + string(a.nonce)) + + // A PLUS authentication requires the channel binding data + if a.isPlus { + msgWithoutProof = []byte("c=" + string(a.bindData) + ",r=" + string(a.nonce)) + } + + a.authMessage = []byte(string(a.firstBareMsg) + "," + string(fromServer) + "," + string(msgWithoutProof)) + clientProof := a.computeClientProof() + + return []byte(string(msgWithoutProof) + ",p=" + string(clientProof)), nil +} + +// handleServerValidationMessage verifies the server's signature during the SCRAM authentication process. +func (a *scramAuth) handleServerValidationMessage(fromServer []byte) ([]byte, error) { + serverSignature := fromServer[2:] + computedServerSignature := a.computeServerSignature() + + if !hmac.Equal(serverSignature, computedServerSignature) { + return nil, errors.New("invalid server signature") + } + return []byte(""), nil +} + +// computeHMAC generates a Hash-based Message Authentication Code (HMAC) using the specified key and message. +func (a *scramAuth) computeHMAC(key, msg []byte) []byte { + mac := hmac.New(a.h, key) + mac.Write(msg) + return mac.Sum(nil) +} + +// computeHash generates a hash of the given key using the configured hashing algorithm. +func (a *scramAuth) computeHash(key []byte) []byte { + hasher := a.h() + hasher.Write(key) + return hasher.Sum(nil) +} + +// computeClientProof generates the client proof as part of the SCRAM authentication process. +func (a *scramAuth) computeClientProof() []byte { + clientKey := a.computeHMAC(a.saltedPwd, []byte("Client Key")) + storedKey := a.computeHash(clientKey) + clientSignature := a.computeHMAC(storedKey[:], a.authMessage) + clientProof := make([]byte, len(clientSignature)) + for i := 0; i < len(clientSignature); i++ { + clientProof[i] = clientKey[i] ^ clientSignature[i] + } + buf := make([]byte, base64.StdEncoding.EncodedLen(len(clientProof))) + base64.StdEncoding.Encode(buf, clientProof) + return buf +} + +// computeServerSignature returns the computed base64-encoded server signature in the SCRAM +// authentication process. +func (a *scramAuth) computeServerSignature() []byte { + serverKey := a.computeHMAC(a.saltedPwd, []byte("Server Key")) + serverSignature := a.computeHMAC(serverKey, a.authMessage) + buf := make([]byte, base64.StdEncoding.EncodedLen(len(serverSignature))) + base64.StdEncoding.Encode(buf, serverSignature) + return buf +} + +// normalizeUsername replaces special characters in the username for SCRAM authentication +// and prepares it using the SASLprep profile as per RFC 8265, returning the normalized +// username or an error. +func (a *scramAuth) normalizeUsername() (string, error) { + // RFC 5802 section 5.1: the characters ',' or '=' in usernames are + // sent as '=2C' and '=3D' respectively. + replacer := strings.NewReplacer("=", "=3D", ",", "=2C") + username := replacer.Replace(a.username) + // RFC 5802 section 5.1: before sending the username to the server, + // the client SHOULD prepare the username using the "SASLprep" + // profile [RFC4013] of the "stringprep" algorithm [RFC3454] + // treating it as a query string (i.e., unassigned Unicode code + // points are allowed). If the preparation of the username fails or + // results in an empty string, the client SHOULD abort the + // authentication exchange. + // + // Since RFC 8265 obsoletes RFC 4013 we use it instead. + username, err := a.normalizeString(username) + if err != nil { + return "", fmt.Errorf("unable to normalize username: %w", err) + } + return username, nil +} + +// normalizeString normalizes the input string according to the OpaqueString profile of the +// precis framework. It returns the normalized string or an error if normalization fails or +// results in an empty string. +func (a *scramAuth) normalizeString(s string) (string, error) { + s, err := precis.OpaqueString.String(s) + if err != nil { + return "", fmt.Errorf("failled to normalize string: %w", err) + } + if s == "" { + return "", errors.New("normalized string is empty") + } + return s, nil +} diff --git a/smtp/smtp.go b/smtp/smtp.go index d2a0e64..d713f8c 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -30,34 +30,70 @@ import ( "net/textproto" "os" "strings" + "sync" + "time" "github.com/wneessen/go-mail/log" ) +var ( + + // ErrNonTLSConnection is returned when an attempt is made to retrieve TLS state on a non-TLS connection. + ErrNonTLSConnection = errors.New("connection is not using TLS") + + // ErrNoConnection is returned when attempting to perform an operation that requires an established + // connection but none exists. + ErrNoConnection = errors.New("connection is not established") +) + // A Client represents a client connection to an SMTP server. type Client struct { - // Text is the textproto.Conn used by the Client. It is exported to allow for - // clients to add extensions. + // Text is the textproto.Conn used by the Client. It is exported to allow for clients to add extensions. Text *textproto.Conn - // keep a reference to the connection so it can be used to create a TLS - // connection later + + // auth supported auth mechanisms + auth []string + + // keep a reference to the connection so it can be used to create a TLS connection later conn net.Conn - // whether the Client is using TLS - tls bool - serverName string - // map of supported extensions + + // debug logging is enabled + debug bool + + // didHello indicates whether we've said HELO/EHLO + didHello bool + + // dsnmrtype defines the mail return option in case DSN is enabled + dsnmrtype string + + // dsnrntype defines the recipient notify option in case DSN is enabled + dsnrntype string + + // ext is a map of supported extensions ext map[string]string - // supported auth mechanisms - auth []string - localName string // the name to use in HELO/EHLO - didHello bool // whether we've said HELO/EHLO - helloError error // the error from the hello - // debug logging - debug bool // debug logging is enabled - logger log.Logger // logger will be used for debug logging - // DSN support - dsnmrtype string // dsnmrtype defines the mail return option in case DSN is enabled - dsnrntype string // dsnrntype defines the recipient notify option in case DSN is enabled + + // helloError is the error from the hello + helloError error + + // isConnected indicates if the Client has an active connection + isConnected bool + + // localName is the name to use in HELO/EHLO + localName string // the name to use in HELO/EHLO + + // logger will be used for debug logging + logger log.Logger + + // mutex is used to synchronize access to shared resources, ensuring that only one goroutine can access + // the resource at a time. + mutex sync.RWMutex + + // tls indicates whether the Client is using TLS + tls bool + + // serverName denotes the name of the server to which the application will connect. Used for + // identification and routing. + serverName string } // Dial returns a new [Client] connected to an SMTP server at addr. @@ -88,13 +124,18 @@ func NewClient(conn net.Conn, host string) (*Client, error) { } c := &Client{Text: text, conn: conn, serverName: host, localName: "localhost"} _, c.tls = conn.(*tls.Conn) + c.isConnected = true return c, nil } // Close closes the connection. func (c *Client) Close() error { - return c.Text.Close() + c.mutex.Lock() + err := c.Text.Close() + c.isConnected = false + c.mutex.Unlock() + return err } // hello runs a hello exchange if needed. @@ -121,28 +162,39 @@ func (c *Client) Hello(localName string) error { if c.didHello { return errors.New("smtp: Hello called after other methods") } + + c.mutex.Lock() c.localName = localName + c.mutex.Unlock() + return c.hello() } // cmd is a convenience function that sends a command and returns the response func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { + c.mutex.Lock() + c.debugLog(log.DirClientToServer, format, args...) id, err := c.Text.Cmd(format, args...) if err != nil { + c.mutex.Unlock() return 0, "", err } c.Text.StartResponse(id) - defer c.Text.EndResponse(id) code, msg, err := c.Text.ReadResponse(expectCode) c.debugLog(log.DirServerToClient, "%d %s", code, msg) + c.Text.EndResponse(id) + c.mutex.Unlock() return code, msg, err } // helo sends the HELO greeting to the server. It should be used only when the // server does not support ehlo. func (c *Client) helo() error { + c.mutex.Lock() c.ext = nil + c.mutex.Unlock() + _, _, err := c.cmd(250, "HELO %s", c.localName) return err } @@ -157,9 +209,13 @@ func (c *Client) StartTLS(config *tls.Config) error { if err != nil { return err } + + c.mutex.Lock() c.conn = tls.Client(c.conn, config) c.Text = textproto.NewConn(c.conn) c.tls = true + c.mutex.Unlock() + return c.ehlo() } @@ -167,11 +223,15 @@ func (c *Client) StartTLS(config *tls.Config) error { // The return values are their zero values if [Client.StartTLS] did // not succeed. func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { + c.mutex.RLock() + defer c.mutex.RUnlock() + tc, ok := c.conn.(*tls.Conn) if !ok { return } - return tc.ConnectionState(), true + state, ok = tc.ConnectionState(), true + return } // Verify checks the validity of an email address on the server. @@ -257,6 +317,8 @@ func (c *Client) Mail(from string) error { return err } cmdStr := "MAIL FROM:<%s>" + + c.mutex.RLock() if c.ext != nil { if _, ok := c.ext["8BITMIME"]; ok { cmdStr += " BODY=8BITMIME" @@ -269,6 +331,8 @@ func (c *Client) Mail(from string) error { cmdStr += fmt.Sprintf(" RET=%s", c.dsnmrtype) } } + c.mutex.RUnlock() + _, _, err := c.cmd(250, cmdStr, from) return err } @@ -280,7 +344,11 @@ func (c *Client) Rcpt(to string) error { if err := validateLine(to); err != nil { return err } + + c.mutex.RLock() _, ok := c.ext["DSN"] + c.mutex.RUnlock() + if ok && c.dsnrntype != "" { _, _, err := c.cmd(25, "RCPT TO:<%s> NOTIFY=%s", to, c.dsnrntype) return err @@ -294,12 +362,23 @@ type dataCloser struct { io.WriteCloser } +// Close releases the lock, closes the WriteCloser, waits for a response, and then returns any error encountered. func (d *dataCloser) Close() error { + d.c.mutex.Lock() _ = d.WriteCloser.Close() _, _, err := d.c.Text.ReadResponse(250) + d.c.mutex.Unlock() return err } +// Write writes data to the underlying WriteCloser while ensuring thread-safety by locking and unlocking a mutex. +func (d *dataCloser) Write(p []byte) (n int, err error) { + d.c.mutex.Lock() + n, err = d.WriteCloser.Write(p) + d.c.mutex.Unlock() + return +} + // Data issues a DATA command to the server and returns a writer that // can be used to write the mail headers and body. The caller should // close the writer before calling any more methods on c. A call to @@ -309,7 +388,14 @@ func (c *Client) Data() (io.WriteCloser, error) { if err != nil { return nil, err } - return &dataCloser{c, c.Text.DotWriter()}, nil + datacloser := &dataCloser{} + + c.mutex.Lock() + datacloser.c = c + datacloser.WriteCloser = c.Text.DotWriter() + c.mutex.Unlock() + + return datacloser, nil } var testHookStartTLS func(*tls.Config) // nil, except for tests @@ -405,7 +491,10 @@ func (c *Client) Extension(ext string) (bool, string) { return false, "" } ext = strings.ToUpper(ext) + + c.mutex.RLock() param, ok := c.ext[ext] + c.mutex.RUnlock() return ok, param } @@ -438,7 +527,12 @@ func (c *Client) Quit() error { if err != nil { return err } - return c.Text.Close() + c.mutex.Lock() + err = c.Text.Close() + c.isConnected = false + c.mutex.Unlock() + + return err } // SetDebugLog enables the debug logging for incoming and outgoing SMTP messages @@ -472,6 +566,44 @@ func (c *Client) SetDSNRcptNotifyOption(d string) { c.dsnrntype = d } +// HasConnection checks if the client has an active connection. +// Returns true if the `conn` field is not nil, indicating an active connection. +func (c *Client) HasConnection() bool { + c.mutex.RLock() + isConn := c.isConnected + c.mutex.RUnlock() + return isConn +} + +// UpdateDeadline sets a new deadline on the SMTP connection with the specified timeout duration. +func (c *Client) UpdateDeadline(timeout time.Duration) error { + c.mutex.Lock() + defer c.mutex.Unlock() + if err := c.conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return fmt.Errorf("smtp: failed to update deadline: %w", err) + } + return nil +} + +// GetTLSConnectionState retrieves the TLS connection state of the client's current connection. +// Returns an error if the connection is not using TLS or if the connection is not established. +func (c *Client) GetTLSConnectionState() (*tls.ConnectionState, error) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.isConnected { + return nil, ErrNoConnection + } + if !c.tls { + return nil, ErrNonTLSConnection + } + if conn, ok := c.conn.(*tls.Conn); ok { + cstate := conn.ConnectionState() + return &cstate, nil + } + return nil, errors.New("unable to retrieve TLS connection state") +} + // debugLog checks if the debug flag is set and if so logs the provided message to // the log.Logger interface func (c *Client) debugLog(d log.Direction, f string, a ...interface{}) { diff --git a/smtp/smtp_ehlo.go b/smtp/smtp_ehlo.go index ae80a62..457be57 100644 --- a/smtp/smtp_ehlo.go +++ b/smtp/smtp_ehlo.go @@ -25,6 +25,9 @@ func (c *Client) ehlo() error { if err != nil { return err } + + c.mutex.Lock() + defer c.mutex.Unlock() ext := make(map[string]string) extList := strings.Split(msg, "\n") if len(extList) > 1 { diff --git a/smtp/smtp_ehlo_117.go b/smtp/smtp_ehlo_117.go index 429f30a..c40297f 100644 --- a/smtp/smtp_ehlo_117.go +++ b/smtp/smtp_ehlo_117.go @@ -22,12 +22,15 @@ import "strings" // should be the preferred greeting for servers that support it. // // Backport of: https://github.com/golang/go/commit/4d8db00641cc9ff4f44de7df9b8c4f4a4f9416ee#diff-4f6f6bdb9891d4dd271f9f31430420a2e44018fe4ee539576faf458bebb3cee4 -// to guarantee backwards compatibility with Go 1.16/1.17:w +// to guarantee backwards compatibility with Go 1.16/1.17 func (c *Client) ehlo() error { _, msg, err := c.cmd(250, "EHLO %s", c.localName) if err != nil { return err } + + c.mutex.Lock() + defer c.mutex.Unlock() ext := make(map[string]string) extList := strings.Split(msg, "\n") if len(extList) > 1 { diff --git a/smtp/smtp_test.go b/smtp/smtp_test.go index 6df6eeb..4fd32eb 100644 --- a/smtp/smtp_test.go +++ b/smtp/smtp_test.go @@ -16,10 +16,15 @@ package smtp import ( "bufio" "bytes" + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/base64" "flag" "fmt" + "hash" "io" "net" "net/textproto" @@ -29,6 +34,8 @@ import ( "testing" "time" + "golang.org/x/crypto/pbkdf2" + "github.com/wneessen/go-mail/log" ) @@ -38,6 +45,7 @@ type authTest struct { name string responses []string sf []bool + hasNonce bool } var authTests = []authTest{ @@ -47,6 +55,7 @@ var authTests = []authTest{ "PLAIN", []string{"\x00user\x00pass"}, []bool{false, false}, + false, }, { PlainAuth("foo", "bar", "baz", "testserver"), @@ -54,13 +63,47 @@ var authTests = []authTest{ "PLAIN", []string{"foo\x00bar\x00baz"}, []bool{false, false}, + false, + }, + { + PlainAuth("foo", "bar", "baz", "testserver"), + []string{"foo"}, + "PLAIN", + []string{"foo\x00bar\x00baz", ""}, + []bool{true}, + false, }, { LoginAuth("user", "pass", "testserver"), - []string{"Username:", "Password:", "User Name\x00", "Password\x00", "Invalid:"}, + []string{"Username:", "Password:"}, "LOGIN", - []string{"", "user", "pass", "user", "pass", ""}, - []bool{false, false, false, false, true}, + []string{"", "user", "pass"}, + []bool{false, false}, + false, + }, + { + LoginAuth("user", "pass", "testserver"), + []string{"User Name\x00", "Password\x00"}, + "LOGIN", + []string{"", "user", "pass"}, + []bool{false, false}, + false, + }, + { + LoginAuth("user", "pass", "testserver"), + []string{"Invalid", "Invalid:"}, + "LOGIN", + []string{"", "user", "pass"}, + []bool{false, false}, + false, + }, + { + LoginAuth("user", "pass", "testserver"), + []string{"Invalid", "Invalid:", "Too many"}, + "LOGIN", + []string{"", "user", "pass", ""}, + []bool{false, false, true}, + false, }, { CRAMMD5Auth("user", "pass"), @@ -68,6 +111,7 @@ var authTests = []authTest{ "CRAM-MD5", []string{"", "user 287eb355114cf5c471c26a875f1ca4ae"}, []bool{false, false}, + false, }, { XOAuth2Auth("username", "token"), @@ -75,6 +119,47 @@ var authTests = []authTest{ "XOAUTH2", []string{"user=username\x01auth=Bearer token\x01\x01", ""}, []bool{false}, + false, + }, + { + ScramSHA1Auth("username", "password"), + []string{"", "r=foo"}, + "SCRAM-SHA-1", + []string{"", "n,,n=username,r=", ""}, + []bool{false, true}, + true, + }, + { + ScramSHA1Auth("username", "password"), + []string{"", "v=foo"}, + "SCRAM-SHA-1", + []string{"", "n,,n=username,r=", ""}, + []bool{false, true}, + true, + }, + { + ScramSHA256Auth("username", "password"), + []string{""}, + "SCRAM-SHA-256", + []string{"", "n,,n=username,r=", ""}, + []bool{false}, + true, + }, + { + ScramSHA1PlusAuth("username", "password", nil), + []string{""}, + "SCRAM-SHA-1-PLUS", + []string{"", "", ""}, + []bool{true}, + true, + }, + { + ScramSHA256PlusAuth("username", "password", nil), + []string{""}, + "SCRAM-SHA-256-PLUS", + []string{"", "", ""}, + []bool{true}, + true, }, } @@ -100,10 +185,20 @@ testLoop: t.Errorf("#%d error: %s", i, err) continue testLoop } + if test.hasNonce { + if !bytes.HasPrefix(resp, expected) { + t.Errorf("#%d got response: %s, expected response to start with: %s", i, resp, expected) + } + continue testLoop + } if !bytes.Equal(resp, expected) { t.Errorf("#%d got %s, expected %s", i, resp, expected) continue testLoop } + _, err = test.auth.Next([]byte("2.7.0 Authentication successful"), false) + if err != nil { + t.Errorf("#%d success message error: %s", i, err) + } } } } @@ -280,6 +375,240 @@ func TestXOAuth2Error(t *testing.T) { } } +func TestAuthSCRAMSHA1_OK(t *testing.T) { + hostname := "127.0.0.1" + port := "2585" + + go func() { + startSMTPServer(false, hostname, port, sha1.New) + }() + time.Sleep(time.Millisecond * 500) + + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port)) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + if err = client.Auth(ScramSHA1Auth("username", "password")); err != nil { + t.Errorf("failed to authenticate: %v", err) + } +} + +func TestAuthSCRAMSHA256_OK(t *testing.T) { + hostname := "127.0.0.1" + port := "2586" + + go func() { + startSMTPServer(false, hostname, port, sha256.New) + }() + time.Sleep(time.Millisecond * 500) + + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port)) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + if err = client.Auth(ScramSHA256Auth("username", "password")); err != nil { + t.Errorf("failed to authenticate: %v", err) + } +} + +func TestAuthSCRAMSHA1PLUS_OK(t *testing.T) { + hostname := "127.0.0.1" + port := "2590" + + go func() { + startSMTPServer(true, hostname, port, sha1.New) + }() + time.Sleep(time.Millisecond * 500) + + cert, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + fmt.Printf("error creating TLS cert: %s", err) + return + } + tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} + + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port), &tlsConfig) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + + tlsConnState := conn.ConnectionState() + if err = client.Auth(ScramSHA1PlusAuth("username", "password", &tlsConnState)); err != nil { + t.Errorf("failed to authenticate: %v", err) + } +} + +func TestAuthSCRAMSHA256PLUS_OK(t *testing.T) { + hostname := "127.0.0.1" + port := "2591" + + go func() { + startSMTPServer(true, hostname, port, sha256.New) + }() + time.Sleep(time.Millisecond * 500) + + cert, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + fmt.Printf("error creating TLS cert: %s", err) + return + } + tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} + + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port), &tlsConfig) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + + tlsConnState := conn.ConnectionState() + if err = client.Auth(ScramSHA256PlusAuth("username", "password", &tlsConnState)); err != nil { + t.Errorf("failed to authenticate: %v", err) + } +} + +func TestAuthSCRAMSHA1_fail(t *testing.T) { + hostname := "127.0.0.1" + port := "2587" + + go func() { + startSMTPServer(false, hostname, port, sha1.New) + }() + time.Sleep(time.Millisecond * 500) + + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port)) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + if err = client.Auth(ScramSHA1Auth("username", "invalid")); err == nil { + t.Errorf("expected auth error, got nil") + } +} + +func TestAuthSCRAMSHA256_fail(t *testing.T) { + hostname := "127.0.0.1" + port := "2588" + + go func() { + startSMTPServer(false, hostname, port, sha256.New) + }() + time.Sleep(time.Millisecond * 500) + + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port)) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + if err = client.Auth(ScramSHA256Auth("username", "invalid")); err == nil { + t.Errorf("expected auth error, got nil") + } +} + +func TestAuthSCRAMSHA1PLUS_fail(t *testing.T) { + hostname := "127.0.0.1" + port := "2592" + + go func() { + startSMTPServer(true, hostname, port, sha1.New) + }() + time.Sleep(time.Millisecond * 500) + + cert, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + fmt.Printf("error creating TLS cert: %s", err) + return + } + tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} + + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port), &tlsConfig) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + tlsConnState := conn.ConnectionState() + if err = client.Auth(ScramSHA1PlusAuth("username", "invalid", &tlsConnState)); err == nil { + t.Errorf("expected auth error, got nil") + } +} + +func TestAuthSCRAMSHA256PLUS_fail(t *testing.T) { + hostname := "127.0.0.1" + port := "2593" + + go func() { + startSMTPServer(true, hostname, port, sha1.New) + }() + time.Sleep(time.Millisecond * 500) + + cert, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + fmt.Printf("error creating TLS cert: %s", err) + return + } + tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true} + + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%s", hostname, port), &tlsConfig) + if err != nil { + t.Errorf("failed to dial server: %v", err) + } + client, err := NewClient(conn, hostname) + if err != nil { + t.Errorf("failed to create client: %v", err) + } + if err = client.Hello(hostname); err != nil { + t.Errorf("failed to send HELO: %v", err) + } + tlsConnState := conn.ConnectionState() + if err = client.Auth(ScramSHA256PlusAuth("username", "invalid", &tlsConnState)); err == nil { + t.Errorf("expected auth error, got nil") + } +} + // Issue 17794: don't send a trailing space on AUTH command when there's no password. func TestClientAuthTrimSpace(t *testing.T) { server := "220 hello world\r\n" + @@ -1311,6 +1640,357 @@ func TestTLSConnState(t *testing.T) { <-serverDone } +func TestClient_GetTLSConnectionState(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + defer func() { + _ = c.Quit() + }() + cfg := &tls.Config{ServerName: "example.com"} + testHookStartTLS(cfg) // set the RootCAs + if err := c.StartTLS(cfg); err != nil { + t.Errorf("StartTLS: %v", err) + return + } + cs, err := c.GetTLSConnectionState() + if err != nil { + t.Errorf("failed to get TLSConnectionState: %s", err) + return + } + if cs.Version == 0 || !cs.HandshakeComplete { + t.Errorf("ConnectionState = %#v; expect non-zero Version and HandshakeComplete", cs) + } + }() + <-clientDone + <-serverDone +} + +func TestClient_GetTLSConnectionState_noTLS(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + defer func() { + _ = c.Quit() + }() + _, err = c.GetTLSConnectionState() + if err == nil { + t.Error("GetTLSConnectionState: expected error; got nil") + return + } + }() + <-clientDone + <-serverDone +} + +func TestClient_GetTLSConnectionState_noConn(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + _ = c.Close() + _, err = c.GetTLSConnectionState() + if err == nil { + t.Error("GetTLSConnectionState: expected error; got nil") + return + } + }() + <-clientDone + <-serverDone +} + +func TestClient_GetTLSConnectionState_unableErr(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + defer func() { + _ = c.Quit() + }() + c.tls = true + _, err = c.GetTLSConnectionState() + if err == nil { + t.Error("GetTLSConnectionState: expected error; got nil") + return + } + }() + <-clientDone + <-serverDone +} + +func TestClient_HasConnection(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + cfg := &tls.Config{ServerName: "example.com"} + testHookStartTLS(cfg) // set the RootCAs + if err := c.StartTLS(cfg); err != nil { + t.Errorf("StartTLS: %v", err) + return + } + if !c.HasConnection() { + t.Error("HasConnection: expected true; got false") + return + } + if err = c.Quit(); err != nil { + t.Errorf("closing connection failed: %s", err) + return + } + if c.HasConnection() { + t.Error("HasConnection: expected false; got true") + } + }() + <-clientDone + <-serverDone +} + +func TestClient_SetDSNMailReturnOption(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + defer func() { + _ = c.Quit() + }() + c.SetDSNMailReturnOption("foo") + if c.dsnmrtype != "foo" { + t.Errorf("SetDSNMailReturnOption: expected %s; got %s", "foo", c.dsnrntype) + } + }() + <-clientDone + <-serverDone +} + +func TestClient_SetDSNRcptNotifyOption(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err := serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + defer func() { + _ = c.Quit() + }() + c.SetDSNRcptNotifyOption("foo") + if c.dsnrntype != "foo" { + t.Errorf("SetDSNMailReturnOption: expected %s; got %s", "foo", c.dsnrntype) + } + }() + <-clientDone + <-serverDone +} + +func TestClient_UpdateDeadline(t *testing.T) { + ln := newLocalListener(t) + defer func() { + _ = ln.Close() + }() + clientDone := make(chan bool) + serverDone := make(chan bool) + go func() { + defer close(serverDone) + c, err := ln.Accept() + if err != nil { + t.Errorf("Server accept: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if err = serverHandle(c, t); err != nil { + t.Errorf("server error: %v", err) + } + }() + go func() { + defer close(clientDone) + c, err := Dial(ln.Addr().String()) + if err != nil { + t.Errorf("Client dial: %v", err) + return + } + defer func() { + _ = c.Close() + }() + if !c.HasConnection() { + t.Error("HasConnection: expected true; got false") + return + } + if err = c.UpdateDeadline(time.Millisecond * 20); err != nil { + t.Errorf("failed to update deadline: %s", err) + return + } + time.Sleep(time.Millisecond * 50) + if !c.HasConnection() { + t.Error("HasConnection: expected true; got false") + return + } + }() + <-clientDone + <-serverDone +} + func newLocalListener(t *testing.T) net.Listener { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -1356,6 +2036,8 @@ func serverHandle(c net.Conn, t *testing.T) error { } config := &tls.Config{Certificates: []tls.Certificate{keypair}} return tf(config) + case "QUIT": + return nil default: t.Fatalf("unrecognized command: %q", s.Text()) } @@ -1453,3 +2135,259 @@ func SkipFlaky(t testing.TB, issue int) { t.Skipf("skipping known flaky test without the -flaky flag; see golang.org/issue/%d", issue) } } + +// 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 +// fields are present. We have actual real authentication tests for all SCRAM modes in the +// go-mail client_test.go +type testSCRAMSMTPServer struct { + authMechanism string + nonce string + hostname string + port string + tlsServer bool + h func() hash.Hash +} + +func (s *testSCRAMSMTPServer) handleConnection(conn net.Conn) { + defer func() { + _ = conn.Close() + }() + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writeLine := func(data string) error { + _, err := writer.WriteString(data + "\r\n") + if err != nil { + return fmt.Errorf("unable to write line: %w", err) + } + return writer.Flush() + } + writeOK := func() { + _ = writeLine("250 2.0.0 OK") + } + + if err := writeLine("220 go-mail test server ready ESMTP"); err != nil { + return + } + + data, err := reader.ReadString('\n') + if err != nil { + return + } + data = strings.TrimSpace(data) + if strings.HasPrefix(data, "EHLO") { + _ = writeLine(fmt.Sprintf("250-%s", s.hostname)) + _ = writeLine("250-AUTH SCRAM-SHA-1 SCRAM-SHA-256") + writeOK() + } else { + _ = writeLine("500 Invalid command") + return + } + + for { + data, err = reader.ReadString('\n') + if err != nil { + fmt.Printf("failed to read data: %v", err) + } + data = strings.TrimSpace(data) + if strings.HasPrefix(data, "AUTH") { + parts := strings.Split(data, " ") + if len(parts) < 2 { + _ = writeLine("500 Syntax error") + return + } + + authMechanism := parts[1] + if authMechanism != "SCRAM-SHA-1" && authMechanism != "SCRAM-SHA-256" && + authMechanism != "SCRAM-SHA-1-PLUS" && authMechanism != "SCRAM-SHA-256-PLUS" { + _ = writeLine("504 Unrecognized authentication mechanism") + return + } + s.authMechanism = authMechanism + _ = writeLine("334 ") + s.handleSCRAMAuth(conn) + return + } else { + _ = writeLine("500 Invalid command") + } + } +} + +func (s *testSCRAMSMTPServer) handleSCRAMAuth(conn net.Conn) { + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + writeLine := func(data string) error { + _, err := writer.WriteString(data + "\r\n") + if err != nil { + return fmt.Errorf("unable to write line: %w", err) + } + return writer.Flush() + } + var authMsg string + + data, err := reader.ReadString('\n') + if err != nil { + _ = writeLine("535 Authentication failed") + return + } + data = strings.TrimSpace(data) + decodedMessage, err := base64.StdEncoding.DecodeString(data) + if err != nil { + _ = writeLine("535 Authentication failed") + return + } + splits := strings.Split(string(decodedMessage), ",") + if len(splits) != 4 { + _ = writeLine("535 Authentication failed - expected 4 parts") + return + } + if !s.tlsServer && splits[0] != "n" { + _ = writeLine("535 Authentication failed - expected n to be in the first part") + return + } + if s.tlsServer && !strings.HasPrefix(splits[0], "p=") { + _ = writeLine("535 Authentication failed - expected p= to be in the first part") + return + } + if splits[2] != "n=username" { + _ = writeLine("535 Authentication failed - expected n=username to be in the third part") + return + } + if !strings.HasPrefix(splits[3], "r=") { + _ = writeLine("535 Authentication failed - expected r= to be in the fourth part") + return + } + authMsg = splits[2] + "," + splits[3] + + clientNonce := s.extractNonce(string(decodedMessage)) + if clientNonce == "" { + _ = writeLine("535 Authentication failed") + return + } + + s.nonce = clientNonce + "server_nonce" + serverFirstMessage := fmt.Sprintf("r=%s,s=%s,i=4096", s.nonce, + base64.StdEncoding.EncodeToString([]byte("salt"))) + _ = writeLine(fmt.Sprintf("334 %s", base64.StdEncoding.EncodeToString([]byte(serverFirstMessage)))) + authMsg = authMsg + "," + serverFirstMessage + + data, err = reader.ReadString('\n') + if err != nil { + _ = writeLine("535 Authentication failed") + return + } + data = strings.TrimSpace(data) + decodedFinalMessage, err := base64.StdEncoding.DecodeString(data) + if err != nil { + _ = writeLine("535 Authentication failed") + return + } + splits = strings.Split(string(decodedFinalMessage), ",") + + if !s.tlsServer && splits[0] != "c=biws" { + _ = writeLine("535 Authentication failed - expected c=biws to be in the first part") + return + } + if s.tlsServer { + if !strings.HasPrefix(splits[0], "c=") { + _ = writeLine("535 Authentication failed - expected c= to be in the first part") + return + } + channelBind, err := base64.StdEncoding.DecodeString(splits[0][2:]) + if err != nil { + _ = writeLine("535 Authentication failed - base64 channel bind is not valid - " + err.Error()) + return + } + if !strings.HasPrefix(string(channelBind), "p=") { + _ = writeLine("535 Authentication failed - expected channel binding to start with p=-") + return + } + cbType := string(channelBind[2:]) + if !strings.HasPrefix(cbType, "tls-unique") && !strings.HasPrefix(cbType, "tls-exporter") { + _ = writeLine("535 Authentication failed - expected channel binding type tls-unique or tls-exporter") + return + } + } + + if !strings.HasPrefix(splits[1], "r=") { + _ = writeLine("535 Authentication failed - expected r to be in the second part") + return + } + if !strings.Contains(splits[1], "server_nonce") { + _ = writeLine("535 Authentication failed - expected server_nonce to be in the second part") + return + } + if !strings.HasPrefix(splits[2], "p=") { + _ = writeLine("535 Authentication failed - expected p to be in the third part") + return + } + + authMsg = authMsg + "," + splits[0] + "," + splits[1] + saltedPwd := pbkdf2.Key([]byte("password"), []byte("salt"), 4096, s.h().Size(), s.h) + mac := hmac.New(s.h, saltedPwd) + mac.Write([]byte("Server Key")) + skey := mac.Sum(nil) + mac.Reset() + + mac = hmac.New(s.h, skey) + mac.Write([]byte(authMsg)) + ssig := mac.Sum(nil) + mac.Reset() + + serverFinalMessage := fmt.Sprintf("v=%s", base64.StdEncoding.EncodeToString(ssig)) + _ = writeLine(fmt.Sprintf("334 %s", base64.StdEncoding.EncodeToString([]byte(serverFinalMessage)))) + + _, err = reader.ReadString('\n') + if err != nil { + _ = writeLine("535 Authentication failed") + return + } + + _ = writeLine("235 Authentication successful") +} + +func (s *testSCRAMSMTPServer) extractNonce(message string) string { + parts := strings.Split(message, ",") + for _, part := range parts { + if strings.HasPrefix(part, "r=") { + return part[2:] + } + } + return "" +} + +func startSMTPServer(tlsServer bool, hostname, port string, h func() hash.Hash) { + server := &testSCRAMSMTPServer{ + hostname: hostname, + port: port, + tlsServer: tlsServer, + h: h, + } + listener, err := net.Listen("tcp", fmt.Sprintf("%s:%s", hostname, port)) + if err != nil { + fmt.Printf("Failed to start SMTP server: %v", err) + } + defer func() { + _ = listener.Close() + }() + + cert, err := tls.X509KeyPair(localhostCert, localhostKey) + if err != nil { + fmt.Printf("error creating TLS cert: %s", err) + return + } + tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}} + + for { + conn, err := listener.Accept() + if err != nil { + fmt.Printf("Failed to accept connection: %v", err) + continue + } + if server.tlsServer { + conn = tls.Server(conn, &tlsConfig) + } + go server.handleConnection(conn) + } +} diff --git a/tls.go b/tls.go index bb7ad14..3adc0cc 100644 --- a/tls.go +++ b/tls.go @@ -4,25 +4,32 @@ package mail -// TLSPolicy type describes a int alias for the different TLS policies we allow +// TLSPolicy is a type wrapper for an int type and describes the different TLS policies we allow. type TLSPolicy int const ( // TLSMandatory requires that the connection to the server is // encrypting using STARTTLS. If the server does not support STARTTLS - // the connection will be terminated with an error + // the connection will be terminated with an error. TLSMandatory TLSPolicy = iota // TLSOpportunistic tries to establish an encrypted connection via the // STARTTLS protocol. If the server does not support this, it will fall - // back to non-encrypted plaintext transmission + // back to non-encrypted plaintext transmission. TLSOpportunistic - // NoTLS forces the transaction to be not encrypted + // NoTLS forces the transaction to be not encrypted. NoTLS ) -// String is a standard method to convert a TLSPolicy into a printable format +// String satisfies the fmt.Stringer interface for the TLSPolicy type. +// +// This function returns a string representation of the TLSPolicy. It matches the policy +// value to predefined constants and returns the corresponding string. If the policy does +// not match any known values, it returns "UnknownPolicy". +// +// Returns: +// - A string representing the TLSPolicy. func (p TLSPolicy) String() string { switch p { case TLSMandatory: