Merge branch 'main' into main

This commit is contained in:
Michael Fuchs 2024-12-07 13:11:45 +01:00 committed by GitHub
commit e3fb10d897
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 5828 additions and 2685 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
github: wneessen
ko_fi: winni

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
name: Bug Report
description: Create a report to help us improve

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
blank_issues_enabled: false
contact_links:

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
name: Feature request
description: Suggest an idea for this project

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
version: 2
updates:

View file

@ -33,12 +33,14 @@ jobs:
PERFORM_ONLINE_TEST: ${{ vars.PERFORM_ONLINE_TEST }}
PERFORM_UNIX_OPEN_WRITE_TESTS: ${{ vars.PERFORM_UNIX_OPEN_WRITE_TESTS }}
PERFORM_SENDMAIL_TESTS: ${{ vars.PERFORM_SENDMAIL_TESTS }}
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
TEST_HOST: ${{ secrets.TEST_HOST }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout Code
@ -50,14 +52,14 @@ jobs:
check-latest: true
- name: Install sendmail
run: |
sudo apt-get -y update >/dev/null && sudo apt-get -y upgrade >/dev/null && sudo DEBIAN_FRONTEND=noninteractive apt-get -y install nullmailer >/dev/null && which sendmail
sudo apt-get -y update && sudo DEBIAN_FRONTEND=noninteractive apt-get -y install nullmailer && which sendmail
- name: Run go test
if: success()
run: |
go test -race -shuffle=on --coverprofile=coverage.coverprofile --covermode=atomic ./...
- name: Upload coverage to Codecov
if: success()
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
uses: codecov/codecov-action@7f8b4b4bde536c465e797be725718b88c5d95e0e # v5.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
lint:
@ -71,7 +73,7 @@ jobs:
go: ['1.23']
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Setup go
@ -93,13 +95,13 @@ jobs:
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout Code
uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master
- name: 'Dependency Review'
uses: actions/dependency-review-action@4081bf99e2866ebe428fc0477b69eb4fcda7220a # v4.4.0
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
with:
base-ref: ${{ github.event.pull_request.base.sha || 'main' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
@ -111,7 +113,7 @@ jobs:
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Run govulncheck
@ -126,9 +128,12 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go: ['1.19', '1.20', '1.21', '1.22', '1.23']
env:
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout Code
@ -149,11 +154,14 @@ jobs:
strategy:
matrix:
osver: ['14.1', '14.0', 13.4']
env:
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
steps:
- name: Checkout Code
uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master
- name: Run go test on FreeBSD
uses: vmactions/freebsd-vm@v1
uses: vmactions/freebsd-vm@debf37ca7b7fa40e19c542ef7ba30d6054a706a4 # v1.1.5
with:
usesh: true
copyback: false
@ -170,13 +178,13 @@ jobs:
cancel-in-progress: true
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout Code
uses: actions/checkout@61b9e3751b92087fd0b06925ba6dd6314e06f089 # master
- name: REUSE Compliance Check
uses: fsfe/reuse-action@3ae3c6bdf1257ab19397fab11fd3312144692083 # v4.0.0
uses: fsfe/reuse-action@bb774aa972c2a89ff34781233d275075cbddf542 # v5.0.0
sonarqube:
name: Test with SonarQube review (${{ matrix.os }} / ${{ matrix.go }})
runs-on: ${{ matrix.os }}
@ -189,12 +197,14 @@ jobs:
go: ['1.23']
env:
PERFORM_ONLINE_TEST: ${{ vars.PERFORM_ONLINE_TEST }}
TEST_BASEPORT: ${{ vars.TEST_BASEPORT }}
TEST_BASEPORT_SMTP: ${{ vars.TEST_BASEPORT_SMTP }}
TEST_HOST: ${{ secrets.TEST_HOST }}
TEST_USER: ${{ secrets.TEST_USER }}
TEST_PASS: ${{ secrets.TEST_PASS }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
- name: Checkout Code
@ -208,7 +218,7 @@ jobs:
run: |
go test -shuffle=on -race --coverprofile=./cov.out ./...
- name: SonarQube scan
uses: sonarsource/sonarqube-scan-action@884b79409bbd464b2a59edc326a4b77dc56b2195 # master
uses: sonarsource/sonarqube-scan-action@1b442ee39ac3fa7c2acdd410208dcb2bcfaae6c4 # master
if: success()
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
@ -45,7 +45,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
@ -54,7 +54,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
uses: github/codeql-action/init@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
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@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
uses: github/codeql-action/autobuild@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
# 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@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
uses: github/codeql-action/analyze@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1
uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2
with:
egress-policy: audit
@ -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@662472033e021d55d94146f66f6058822b0b39fd # v3.27.0
uses: github/codeql-action/upload-sarif@aa578102511db1f4524ed59b8cc2bae4f6e88195 # v3.27.6
with:
sarif_file: results.sarif

2
.gitignore vendored
View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
# Binaries for programs and plugins
*.exe

View file

@ -9,4 +9,73 @@ exclude-dirs = ["examples"]
[linters]
enable = ["stylecheck", "whitespace", "containedctx", "contextcheck", "decorder",
"errname", "errorlint", "gofmt", "gofumpt"]
"errname", "errorlint", "gofmt", "gofumpt", "gosec"]
[issues]
## An overflow is impossible here
[[issues.exclude-rules]]
linters = ["gosec"]
path = "random.go"
text = "G115:"
## These are tests which intentionally do not need any TLS settings
[[issues.exclude-rules]]
linters = ["gosec"]
path = "client_test.go"
text = "G402:"
## These are tests which intentionally do not need any TLS settings
[[issues.exclude-rules]]
linters = ["gosec"]
path = "smtp/smtp_test.go"
text = "G402:"
## We do not dictate a TLS minimum version in the smtp package. go-mail
## itself does set sane defaults
[[issues.exclude-rules]]
linters = ["gosec"]
path = "smtp/smtp.go"
text = "G402:"
## The chance that we write +2 million tests is very low, I think we can
## ignore this for the time being
[[issues.exclude-rules]]
linters = ["gosec"]
path = "client_test.go"
text = "G109:"
## The chance that we write +2 million tests is very low, I think we can
## ignore this for the time being
[[issues.exclude-rules]]
linters = ["gosec"]
path = "smtp/smtp_test.go"
text = "G109:"
## We inform the user about the deprecated status of CRAM-MD5 and suggest
## to use SCRAM-SHA instead
[[issues.exclude-rules]]
linters = ["gosec"]
path = "smtp/auth_cram_md5.go"
text = "G501:"
## Yes, SHA1 is weak, but in the context of SCRAM it is still considered
## secure for specific applications. The user is information about this
## in the documentation
[[issues.exclude-rules]]
linters = ["gosec"]
path = "smtp/auth_scram.go"
text = "G505:"
## Test code for SCRAM-SHA1. Can be ignored.
[[issues.exclude-rules]]
linters = ["gosec"]
path = "smtp/smtp_test.go"
text = "G505:"
## These are tests which intentionally do not need any TLS settings
[[issues.exclude-rules]]
linters = ["gosec"]
path = "quicksend_test.go"
text = "G402:"

View file

@ -1,7 +1,7 @@
<!--
SPDX-FileCopyrightText: 2022 Winni Neessen <winni@neessen.dev>
SPDX-License-Identifier: CC0-1.0
SPDX-License-Identifier: MIT
-->
# Contributor Covenant Code of Conduct

View file

@ -1,7 +1,7 @@
<!--
SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
SPDX-License-Identifier: CC0-1.0
SPDX-License-Identifier: MIT
-->
# How to contribute

View file

@ -1,121 +0,0 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View file

@ -1,7 +1,7 @@
<!--
SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
SPDX-License-Identifier: CC0-1.0
SPDX-License-Identifier: MIT
-->
# go-mail - Easy to use, yet comprehensive library for sending mails with Go
@ -39,14 +39,20 @@ Here are some highlights of go-mail's featureset:
* [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] Implicit SSL/TLS support
* [X] Explicit 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, SCRAM-SHA-1(-PLUS), SCRAM-SHA-256(-PLUS))
* [X] SMTP Auth support
* [X] CRAM-MD5
* [X] LOGIN
* [X] PLAIN
* [X] SCRAM-SHA-1/SCRAM-SHA-1-PLUS
* [X] SCRAM-SHA-256/SCRAM-SHA-256-PLUS
* [X] XOAUTH2
* [X] RFC5322 compliant mail address validation
* [X] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, Priority, etc.)
* [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 attachments and inline embeds (from file system, `io.Reader`, `embed.FS` or `fs.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

View file

@ -1,7 +1,7 @@
<!--
SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
SPDX-License-Identifier: CC0-1.0
SPDX-License-Identifier: MIT
-->
# Security Policy

22
auth.go
View file

@ -136,6 +136,21 @@ const (
//
// https://datatracker.ietf.org/doc/html/rfc7677
SMTPAuthSCRAMSHA256PLUS SMTPAuthType = "SCRAM-SHA-256-PLUS"
// SMTPAuthAutoDiscover is a mechanism that dynamically discovers all authentication mechanisms
// supported by the SMTP server and selects the strongest available one.
//
// This type simplifies authentication by automatically negotiating the most secure mechanism
// offered by the server, based on a predefined security ranking. For instance, mechanisms like
// SCRAM-SHA-256(-PLUS) or XOAUTH2 are prioritized over weaker mechanisms such as CRAM-MD5 or PLAIN.
//
// The negotiation process ensures that mechanisms requiring additional capabilities (e.g.,
// SCRAM-SHA-X-PLUS with TLS channel binding) are only selected when the necessary prerequisites
// are in place, such as an active TLS-secured connection.
//
// By automating mechanism selection, SMTPAuthAutoDiscover minimizes configuration effort while
// maximizing security and compatibility with a wide range of SMTP servers.
SMTPAuthAutoDiscover SMTPAuthType = "AUTODISCOVER"
)
// SMTP Auth related static errors
@ -170,12 +185,19 @@ var (
// 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")
// ErrNoSupportedAuthDiscovered is returned when the SMTP Auth AutoDiscover process fails to identify
// any supported authentication mechanisms offered by the server.
ErrNoSupportedAuthDiscovered = errors.New("SMTP Auth autodiscover was not able to detect a supported " +
"authentication mechanism")
)
// UnmarshalString satisfies the fig.StringUnmarshaler interface for the SMTPAuthType type
// https://pkg.go.dev/github.com/kkyr/fig#StringUnmarshaler
func (sa *SMTPAuthType) UnmarshalString(value string) error {
switch strings.ToLower(value) {
case "auto", "autodiscover", "autodiscovery":
*sa = SMTPAuthAutoDiscover
case "cram-md5", "crammd5", "cram":
*sa = SMTPAuthCramMD5
case "custom":

View file

@ -12,6 +12,9 @@ func TestSMTPAuthType_UnmarshalString(t *testing.T) {
authString string
expected SMTPAuthType
}{
{"AUTODISCOVER: auto", "auto", SMTPAuthAutoDiscover},
{"AUTODISCOVER: autodiscover", "autodiscover", SMTPAuthAutoDiscover},
{"AUTODISCOVER: autodiscovery", "autodiscovery", SMTPAuthAutoDiscover},
{"CRAM-MD5: cram-md5", "cram-md5", SMTPAuthCramMD5},
{"CRAM-MD5: crammd5", "crammd5", SMTPAuthCramMD5},
{"CRAM-MD5: cram", "cram", SMTPAuthCramMD5},

374
client.go
View file

@ -142,9 +142,6 @@ type (
// host is the hostname of the SMTP server we are connecting to.
host string
// isEncrypted indicates wether the Client connection is encrypted or not.
isEncrypted bool
// logAuthData indicates whether authentication-related data should be logged.
logAuthData bool
@ -170,6 +167,9 @@ type (
// requestDSN indicates wether we want to request DSN (Delivery Status Notifications).
requestDSN bool
// sendMutex is used to synchronize access to shared resources during the dial and send methods.
sendMutex sync.Mutex
// smtpAuth is the authentication type that is used to authenticate the user with SMTP server. It
// satisfies the smtp.Auth interface.
//
@ -803,7 +803,7 @@ func (c *Client) SetSSLPort(ssl bool, fallback bool) {
}
// 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.
// and outgoing communication between the client and the server to log.Logger that is defined on the Client.
//
// 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
@ -812,9 +812,26 @@ func (c *Client) SetSSLPort(ssl bool, fallback bool) {
// Parameters:
// - val: A boolean value indicating whether to enable (true) or disable (false) debug logging.
func (c *Client) SetDebugLog(val bool) {
c.SetDebugLogWithSMTPClient(c.smtpClient, val)
}
// SetDebugLogWithSMTPClient sets or overrides whether the provided smtp.Client is using debug logging.
// The debug logger will log incoming and outgoing communication between the client and the server to
// log.Logger that is defined on the Client.
//
// 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:
// - client: A pointer to the smtp.Client that handles the connection to the server.
// - val: A boolean value indicating whether to enable (true) or disable (false) debug logging.
func (c *Client) SetDebugLogWithSMTPClient(client *smtp.Client, val bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.useDebugLog = val
if c.smtpClient != nil {
c.smtpClient.SetDebugLog(val)
if client != nil {
client.SetDebugLog(val)
}
}
@ -827,9 +844,24 @@ func (c *Client) SetDebugLog(val bool) {
// Parameters:
// - logger: A logger that satisfies the log.Logger interface to be set for the Client.
func (c *Client) SetLogger(logger log.Logger) {
c.SetLoggerWithSMTPClient(c.smtpClient, logger)
}
// SetLoggerWithSMTPClient sets or overrides the custom logger currently used by the provided smtp.Client.
// The logger must satisfy the log.Logger interface and is only utilized when debug logging is enabled on
// the provided smtp.Client.
//
// By default, log.Stdlog is used if no custom logger is provided.
//
// Parameters:
// - client: A pointer to the smtp.Client that handles the connection to the server.
// - logger: A logger that satisfies the log.Logger interface to be set for the Client.
func (c *Client) SetLoggerWithSMTPClient(client *smtp.Client, logger log.Logger) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.logger = logger
if c.smtpClient != nil {
c.smtpClient.SetLogger(logger)
if client != nil {
client.SetLogger(logger)
}
}
@ -923,67 +955,91 @@ func (c *Client) SetLogAuthData(logAuth bool) {
// SMTP server.
//
// Parameters:
// - dialCtx: The context.Context used to control the connection timeout and cancellation.
// - ctxDial: 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()
if c.dialContextFunc == nil {
netDialer := net.Dialer{}
c.dialContextFunc = netDialer.DialContext
if c.useSSL {
tlsDialer := tls.Dialer{NetDialer: &netDialer, Config: c.tlsconfig}
c.isEncrypted = true
c.dialContextFunc = tlsDialer.DialContext
}
}
connection, err := c.dialContextFunc(ctx, "tcp", c.ServerAddr())
if err != nil && c.fallbackPort != 0 {
// TODO: should we somehow log or append the previous error?
connection, err = c.dialContextFunc(ctx, "tcp", c.serverFallbackAddr())
}
func (c *Client) DialWithContext(ctxDial context.Context) error {
client, err := c.DialToSMTPClientWithContext(ctxDial)
if err != nil {
return err
}
c.mutex.Lock()
c.smtpClient = client
c.mutex.Unlock()
return nil
}
// DialToSMTPClientWithContext establishes and configures a smtp.Client connection using
// the provided context.
//
// This function uses the provided context to manage the connection deadline and cancellation.
// It dials the SMTP server using the Client's configured DialContextFunc or a default dialer.
// If SSL is enabled, it uses a TLS connection. After successfully connecting, it initializes
// an smtp.Client, sends the HELO/EHLO command, and optionally performs STARTTLS and SMTP AUTH
// based on the Client's configuration. Debug and authentication logging are enabled if
// configured.
//
// Parameters:
// - ctxDial: The context used to control the connection timeout and cancellation.
//
// Returns:
// - A pointer to the initialized smtp.Client.
// - An error if the connection fails, the smtp.Client cannot be created, or any subsequent commands fail.
func (c *Client) DialToSMTPClientWithContext(ctxDial context.Context) (*smtp.Client, error) {
c.mutex.RLock()
defer c.mutex.RUnlock()
ctx, cancel := context.WithDeadline(ctxDial, time.Now().Add(c.connTimeout))
defer cancel()
isEncrypted := false
dialContextFunc := c.dialContextFunc
if c.dialContextFunc == nil {
netDialer := net.Dialer{}
dialContextFunc = netDialer.DialContext
if c.useSSL {
tlsDialer := tls.Dialer{NetDialer: &netDialer, Config: c.tlsconfig}
isEncrypted = true
dialContextFunc = tlsDialer.DialContext
}
}
connection, err := dialContextFunc(ctx, "tcp", c.ServerAddr())
if err != nil && c.fallbackPort != 0 {
// TODO: should we somehow log or append the previous error?
connection, err = dialContextFunc(ctx, "tcp", c.serverFallbackAddr())
}
if err != nil {
return nil, err
}
client, err := smtp.NewClient(connection, c.host)
if err != nil {
return err
return nil, err
}
if client == nil {
return fmt.Errorf("SMTP client is nil")
}
c.smtpClient = client
if c.logger != nil {
c.smtpClient.SetLogger(c.logger)
client.SetLogger(c.logger)
}
if c.useDebugLog {
c.smtpClient.SetDebugLog(true)
client.SetDebugLog(true)
}
if c.logAuthData {
c.smtpClient.SetLogAuthData()
client.SetLogAuthData()
}
if err = c.smtpClient.Hello(c.helo); err != nil {
return err
if err = client.Hello(c.helo); err != nil {
return nil, err
}
if err = c.tls(); err != nil {
return err
if err = c.tls(client, &isEncrypted); err != nil {
return nil, err
}
if err = c.auth(); err != nil {
return err
if err = c.auth(client, isEncrypted); err != nil {
return nil, err
}
return nil
return client, nil
}
// Close terminates the connection to the SMTP server, returning an error if the disconnection
@ -996,10 +1052,27 @@ func (c *Client) DialWithContext(dialCtx context.Context) error {
// Returns:
// - An error if the disconnection fails; otherwise, returns nil.
func (c *Client) Close() error {
if !c.smtpClient.HasConnection() {
return c.CloseWithSMTPClient(c.smtpClient)
}
// CloseWithSMTPClient terminates the connection of the provided smtp.Client 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 smtp.Client 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.
//
// Parameters:
// - client: A pointer to the smtp.Client that handles the connection to the server.
//
// Returns:
// - An error if the disconnection fails; otherwise, returns nil.
func (c *Client) CloseWithSMTPClient(client *smtp.Client) error {
if client == nil || !client.HasConnection() {
return nil
}
if err := c.smtpClient.Quit(); err != nil {
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to close SMTP client: %w", err)
}
@ -1013,12 +1086,29 @@ func (c *Client) Close() error {
// 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.
// - 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 c.ResetWithSMTPClient(c.smtpClient)
}
// ResetWithSMTPClient sends an SMTP RSET command to the provided smtp.Client, 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.
//
// Parameters:
// - client: A pointer to the smtp.Client that handles the connection to the server.
//
// Returns:
// - An error if the connection check fails or if sending the RSET command fails; otherwise, returns nil.
func (c *Client) ResetWithSMTPClient(client *smtp.Client) error {
if err := c.checkConn(client); err != nil {
return err
}
if err := c.smtpClient.Reset(); err != nil {
if err := client.Reset(); err != nil {
return fmt.Errorf("failed to send RSET to SMTP client: %w", err)
}
@ -1058,22 +1148,47 @@ func (c *Client) DialAndSend(messages ...*Msg) error {
// - 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 {
client, err := c.DialToSMTPClientWithContext(ctx)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
defer func() {
_ = c.Close()
_ = c.CloseWithSMTPClient(client)
}()
if err := c.Send(messages...); err != nil {
if err = c.SendWithSMTPClient(client, messages...); err != nil {
return fmt.Errorf("send failed: %w", err)
}
if err := c.Close(); err != nil {
if err = c.CloseWithSMTPClient(client); err != nil {
return fmt.Errorf("failed to close connection: %w", err)
}
return nil
}
// Send attempts to send one or more Msg using the SMTP client that is assigned to the Client.
// 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:
// - client: A pointer to the smtp.Client that holds the connection to the SMTP server
// - 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) (returnErr error) {
c.sendMutex.Lock()
defer c.sendMutex.Unlock()
return c.SendWithSMTPClient(c.smtpClient, messages...)
}
// 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.
@ -1093,85 +1208,125 @@ func (c *Client) DialAndSendWithContext(ctx context.Context, messages ...*Msg) e
// 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 {
func (c *Client) auth(client *smtp.Client, isEnc bool) error {
var smtpAuth smtp.Auth
if c.smtpAuth == nil && c.smtpAuthType != SMTPAuthNoAuth {
hasSMTPAuth, smtpAuthType := c.smtpClient.Extension("AUTH")
hasSMTPAuth, smtpAuthType := client.Extension("AUTH")
if !hasSMTPAuth {
return fmt.Errorf("server does not support SMTP AUTH")
}
switch c.smtpAuthType {
authType := c.smtpAuthType
if c.smtpAuthType == SMTPAuthAutoDiscover {
discoveredType, err := c.authTypeAutoDiscover(smtpAuthType, isEnc)
if err != nil {
return err
}
authType = discoveredType
}
switch authType {
case SMTPAuthPlain:
if !strings.Contains(smtpAuthType, string(SMTPAuthPlain)) {
return ErrPlainAuthNotSupported
}
c.smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host, false)
smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host, false)
case SMTPAuthPlainNoEnc:
if !strings.Contains(smtpAuthType, string(SMTPAuthPlain)) {
return ErrPlainAuthNotSupported
}
c.smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host, true)
smtpAuth = smtp.PlainAuth("", c.user, c.pass, c.host, true)
case SMTPAuthLogin:
if !strings.Contains(smtpAuthType, string(SMTPAuthLogin)) {
return ErrLoginAuthNotSupported
}
c.smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host, false)
smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host, false)
case SMTPAuthLoginNoEnc:
if !strings.Contains(smtpAuthType, string(SMTPAuthLogin)) {
return ErrLoginAuthNotSupported
}
c.smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host, true)
smtpAuth = smtp.LoginAuth(c.user, c.pass, c.host, true)
case SMTPAuthCramMD5:
if !strings.Contains(smtpAuthType, string(SMTPAuthCramMD5)) {
return ErrCramMD5AuthNotSupported
}
c.smtpAuth = smtp.CRAMMD5Auth(c.user, c.pass)
smtpAuth = smtp.CRAMMD5Auth(c.user, c.pass)
case SMTPAuthXOAUTH2:
if !strings.Contains(smtpAuthType, string(SMTPAuthXOAUTH2)) {
return ErrXOauth2AuthNotSupported
}
c.smtpAuth = smtp.XOAuth2Auth(c.user, c.pass)
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)
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)
smtpAuth = smtp.ScramSHA256Auth(c.user, c.pass)
case SMTPAuthSCRAMSHA1PLUS:
if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA1PLUS)) {
return ErrSCRAMSHA1PLUSAuthNotSupported
}
tlsConnState, err := c.smtpClient.GetTLSConnectionState()
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
return err
}
c.smtpAuth = smtp.ScramSHA1PlusAuth(c.user, c.pass, tlsConnState)
smtpAuth = smtp.ScramSHA1PlusAuth(c.user, c.pass, tlsConnState)
case SMTPAuthSCRAMSHA256PLUS:
if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA256PLUS)) {
return ErrSCRAMSHA256PLUSAuthNotSupported
}
tlsConnState, err := c.smtpClient.GetTLSConnectionState()
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
return err
}
c.smtpAuth = smtp.ScramSHA256PlusAuth(c.user, c.pass, tlsConnState)
smtpAuth = smtp.ScramSHA256PlusAuth(c.user, c.pass, tlsConnState)
default:
return fmt.Errorf("unsupported SMTP AUTH type %q", c.smtpAuthType)
}
}
if c.smtpAuth != nil {
if err := c.smtpClient.Auth(c.smtpAuth); err != nil {
if smtpAuth != nil {
if err := client.Auth(smtpAuth); err != nil {
return fmt.Errorf("SMTP AUTH failed: %w", err)
}
}
return nil
}
func (c *Client) authTypeAutoDiscover(supported string, isEnc bool) (SMTPAuthType, error) {
if supported == "" {
return "", ErrNoSupportedAuthDiscovered
}
preferList := []SMTPAuthType{
SMTPAuthSCRAMSHA256PLUS, SMTPAuthSCRAMSHA256, SMTPAuthSCRAMSHA1PLUS, SMTPAuthSCRAMSHA1,
SMTPAuthXOAUTH2, SMTPAuthCramMD5, SMTPAuthPlain, SMTPAuthLogin,
}
if !isEnc {
preferList = []SMTPAuthType{SMTPAuthSCRAMSHA256, SMTPAuthSCRAMSHA1, SMTPAuthXOAUTH2, SMTPAuthCramMD5}
}
mechs := strings.Split(supported, " ")
for _, item := range preferList {
if sliceContains(mechs, string(item)) {
return item, nil
}
}
return "", ErrNoSupportedAuthDiscovered
}
func sliceContains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// sendSingleMsg sends out a single message and returns an error if the transmission or
// delivery fails. It is invoked by the public Send methods.
//
@ -1187,12 +1342,13 @@ func (c *Client) auth() error {
//
// 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()
func (c *Client) sendSingleMsg(client *smtp.Client, message *Msg) error {
c.mutex.RLock()
defer c.mutex.RUnlock()
escSupport, _ := client.Extension("ENHANCEDSTATUSCODES")
if message.encoding == NoEncoding {
if ok, _ := c.smtpClient.Extension("8BITMIME"); !ok {
if ok, _ := client.Extension("8BITMIME"); !ok {
return &SendError{Reason: ErrNoUnencoded, isTemp: false, affectedMsg: message}
}
}
@ -1200,28 +1356,31 @@ func (c *Client) sendSingleMsg(message *Msg) error {
if err != nil {
return &SendError{
Reason: ErrGetSender, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
rcpts, err := message.GetRecipients()
if err != nil {
return &SendError{
Reason: ErrGetRcpts, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
if c.requestDSN {
if c.dsnReturnType != "" {
c.smtpClient.SetDSNMailReturnOption(string(c.dsnReturnType))
client.SetDSNMailReturnOption(string(c.dsnReturnType))
}
}
if err = c.smtpClient.Mail(from); err != nil {
if err = client.Mail(from); err != nil {
retError := &SendError{
Reason: ErrSMTPMailFrom, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil {
if resetSendErr := client.Reset(); resetSendErr != nil {
retError.errlist = append(retError.errlist, resetSendErr)
}
return retError
@ -1231,48 +1390,54 @@ func (c *Client) sendSingleMsg(message *Msg) error {
rcptSendErr.errlist = make([]error, 0)
rcptSendErr.rcpt = make([]string, 0)
rcptNotifyOpt := strings.Join(c.dsnRcptNotifyType, ",")
c.smtpClient.SetDSNRcptNotifyOption(rcptNotifyOpt)
client.SetDSNRcptNotifyOption(rcptNotifyOpt)
for _, rcpt := range rcpts {
if err = c.smtpClient.Rcpt(rcpt); err != nil {
if err = client.Rcpt(rcpt); err != nil {
rcptSendErr.Reason = ErrSMTPRcptTo
rcptSendErr.errlist = append(rcptSendErr.errlist, err)
rcptSendErr.rcpt = append(rcptSendErr.rcpt, rcpt)
rcptSendErr.isTemp = isTempError(err)
rcptSendErr.errcode = errorCode(err)
rcptSendErr.enhancedStatusCode = enhancedStatusCode(err, escSupport)
hasError = true
}
}
if hasError {
if resetSendErr := c.smtpClient.Reset(); resetSendErr != nil {
if resetSendErr := client.Reset(); resetSendErr != nil {
rcptSendErr.errlist = append(rcptSendErr.errlist, resetSendErr)
}
return rcptSendErr
}
writer, err := c.smtpClient.Data()
writer, err := client.Data()
if err != nil {
return &SendError{
Reason: ErrSMTPData, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
_, err = message.WriteTo(writer)
if err != nil {
return &SendError{
Reason: ErrWriteContent, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
if err = writer.Close(); err != nil {
return &SendError{
Reason: ErrSMTPDataClose, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
message.isDelivered = true
if err = c.Reset(); err != nil {
if err = c.ResetWithSMTPClient(client); err != nil {
return &SendError{
Reason: ErrSMTPReset, errlist: []error{err}, isTemp: isTempError(err),
affectedMsg: message,
affectedMsg: message, errcode: errorCode(err),
enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
return nil
@ -1290,21 +1455,24 @@ func (c *Client) sendSingleMsg(message *Msg) error {
// 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 == nil {
func (c *Client) checkConn(client *smtp.Client) error {
if client == nil {
return ErrNoActiveConnection
}
if !c.smtpClient.HasConnection() {
if !client.HasConnection() {
return ErrNoActiveConnection
}
if !c.noNoop {
if err := c.smtpClient.Noop(); err != nil {
c.mutex.RLock()
noNoop := c.noNoop
c.mutex.RUnlock()
if !noNoop {
if err := client.Noop(); err != nil {
return ErrNoActiveConnection
}
}
if err := c.smtpClient.UpdateDeadline(c.connTimeout); err != nil {
if err := client.UpdateDeadline(c.connTimeout); err != nil {
return ErrDeadlineExtendFailed
}
return nil
@ -1351,10 +1519,10 @@ func (c *Client) setDefaultHelo() error {
// 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 {
func (c *Client) tls(client *smtp.Client, isEnc *bool) error {
if !c.useSSL && c.tlspolicy != NoTLS {
hasStartTLS := false
extension, _ := c.smtpClient.Extension("STARTTLS")
extension, _ := client.Extension("STARTTLS")
if c.tlspolicy == TLSMandatory {
hasStartTLS = true
if !extension {
@ -1368,21 +1536,21 @@ func (c *Client) tls() error {
}
}
if hasStartTLS {
if err := c.smtpClient.StartTLS(c.tlsconfig); err != nil {
if err := client.StartTLS(c.tlsconfig); err != nil {
return err
}
}
tlsConnState, err := c.smtpClient.GetTLSConnectionState()
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
switch {
case errors.Is(err, smtp.ErrNonTLSConnection):
c.isEncrypted = false
*isEnc = false
return nil
default:
return fmt.Errorf("failed to get TLS connection state: %w", err)
}
}
c.isEncrypted = tlsConnState.HandshakeComplete
*isEnc = tlsConnState.HandshakeComplete
}
return nil
}

View file

@ -7,12 +7,16 @@
package mail
import "errors"
import (
"errors"
// 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.
"github.com/wneessen/go-mail/smtp"
)
// SendWithSMTPClient attempts to send one or more Msg using a provided smtp.Client with an
// established connection to the SMTP server. If the smtp.Client has no active connection to
// the server, SendWithSMTPClient 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
@ -21,18 +25,26 @@ import "errors"
// them into a single SendError to be returned.
//
// Parameters:
// - client: A pointer to the smtp.Client that holds the connection to the SMTP server
// - 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 err := c.checkConn(); err != nil {
return &SendError{Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err)}
func (c *Client) SendWithSMTPClient(client *smtp.Client, messages ...*Msg) error {
escSupport := false
if client != nil {
escSupport, _ = client.Extension("ENHANCEDSTATUSCODES")
}
if err := c.checkConn(client); err != nil {
return &SendError{
Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err),
errcode: errorCode(err), enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
}
var errs []*SendError
for id, message := range messages {
if sendErr := c.sendSingleMsg(message); sendErr != nil {
if sendErr := c.sendSingleMsg(client, message); sendErr != nil {
messages[id].sendError = sendErr
var msgSendErr *SendError
@ -50,9 +62,11 @@ func (c *Client) Send(messages ...*Msg) error {
returnErr.rcpt = append(returnErr.rcpt, errs[i].rcpt...)
}
// We assume that the isTemp flag from the last error we received should be the
// We assume that the error codes and flags from the last error we received should be the
// indicator for the returned isTemp flag as well
returnErr.isTemp = errs[len(errs)-1].isTemp
returnErr.errcode = errs[len(errs)-1].errcode
returnErr.enhancedStatusCode = errs[len(errs)-1].enhancedStatusCode
return returnErr
}

View file

@ -9,26 +9,38 @@ package mail
import (
"errors"
"github.com/wneessen/go-mail/smtp"
)
// 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.
// SendWithSMTPClient attempts to send one or more Msg using a provided smtp.Client with an
// established connection to the SMTP server. If the smtp.Client has no active connection to
// the server, SendWithSMTPClient 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.
// 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:
// - client: A pointer to the smtp.Client that holds the connection to the SMTP server
// - 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)}
// - An error that represents the sending result, which may include multiple SendErrors if
// any occurred; otherwise, returns nil.
func (c *Client) SendWithSMTPClient(client *smtp.Client, messages ...*Msg) (returnErr error) {
escSupport := false
if client != nil {
escSupport, _ = client.Extension("ENHANCEDSTATUSCODES")
}
if err := c.checkConn(client); err != nil {
returnErr = &SendError{
Reason: ErrConnCheck, errlist: []error{err}, isTemp: isTempError(err),
errcode: errorCode(err), enhancedStatusCode: enhancedStatusCode(err, escSupport),
}
return
}
@ -38,7 +50,7 @@ func (c *Client) Send(messages ...*Msg) (returnErr error) {
}()
for id, message := range messages {
if sendErr := c.sendSingleMsg(message); sendErr != nil {
if sendErr := c.sendSingleMsg(client, message); sendErr != nil {
messages[id].sendError = sendErr
errs = append(errs, sendErr)
}

View file

@ -17,6 +17,7 @@ import (
"net/mail"
"os"
"reflect"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -34,14 +35,15 @@ const (
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 = 12025
// TestSenderValid is a test sender email address considered valid for sending test emails.
TestSenderValid = "valid-from@domain.tld"
// TestRcptValid is a test recipient email address considered valid for sending test emails.
TestRcptValid = "valid-to@domain.tld"
)
// TestServerPortBase is the base port for the simple SMTP test server
var TestServerPortBase int32 = 30025
// PortAdder is an atomic counter used to increment port numbers for the test SMTP server instances.
var PortAdder atomic.Int32
@ -98,6 +100,18 @@ type logData struct {
Lines []logLine `json:"lines"`
}
func init() {
testPort := os.Getenv("TEST_BASEPORT")
if testPort == "" {
return
}
if port, err := strconv.Atoi(testPort); err == nil {
if port <= 65000 && port > 1023 {
TestServerPortBase = int32(port)
}
}
}
func TestNewClient(t *testing.T) {
t.Run("create new Client", func(t *testing.T) {
client, err := NewClient(DefaultHost)
@ -1647,6 +1661,15 @@ func TestClient_Close(t *testing.T) {
t.Errorf("close was supposed to fail, but didn't")
}
})
t.Run("close on a nil smtpclient should return nil", func(t *testing.T) {
client, err := NewClient(DefaultHost)
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
if err = client.Close(); err != nil {
t.Errorf("failed to close the client: %s", err)
}
})
}
func TestClient_DialWithContext(t *testing.T) {
@ -1749,11 +1772,8 @@ func TestClient_DialWithContext(t *testing.T) {
t.Errorf("failed to close the client: %s", err)
}
})
if client.smtpClient == nil {
t.Errorf("client with invalid HELO should still have a smtp client, got nil")
}
if !client.smtpClient.HasConnection() {
t.Errorf("client with invalid HELO should still have a smtp client connection, got nil")
if client.smtpClient != nil {
t.Error("client with invalid HELO should not have a smtp client")
}
})
t.Run("fail on base port and fallback", func(t *testing.T) {
@ -1802,11 +1822,8 @@ func TestClient_DialWithContext(t *testing.T) {
if err = client.DialWithContext(ctxDial); err == nil {
t.Fatalf("connection was supposed to fail, but didn't")
}
if client.smtpClient == nil {
t.Fatalf("client has no smtp client")
}
if !client.smtpClient.HasConnection() {
t.Errorf("client has no connection")
if client.smtpClient != nil {
t.Fatalf("client is not supposed to have a smtp client")
}
})
t.Run("connect with failing auth", func(t *testing.T) {
@ -2274,6 +2291,84 @@ func TestClient_DialAndSendWithContext(t *testing.T) {
t.Errorf("client was supposed to fail on dial")
}
})
// https://github.com/wneessen/go-mail/issues/380
t.Run("concurrent sending via DialAndSendWithContext", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort,
}); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS))
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
wg := sync.WaitGroup{}
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
msg := testMessage(t)
msg.SetMessageIDWithValue("this.is.a.message.id")
ctxDial, cancelDial := context.WithTimeout(ctx, time.Minute)
defer cancelDial()
if goroutineErr := client.DialAndSendWithContext(ctxDial, msg); goroutineErr != nil {
t.Errorf("failed to dial and send message: %s", goroutineErr)
}
}()
}
wg.Wait()
})
// https://github.com/wneessen/go-mail/issues/385
t.Run("concurrent sending via DialAndSendWithContext on receiver func", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort,
}); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS))
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
sender := testSender{client}
ctxDial := context.Background()
wg := sync.WaitGroup{}
for i := 0; i < 5; i++ {
wg.Add(1)
msg := testMessage(t)
go func() {
defer wg.Done()
if goroutineErr := sender.Send(ctxDial, msg); goroutineErr != nil {
t.Errorf("failed to send message: %s", goroutineErr)
}
}()
}
wg.Wait()
})
}
func TestClient_auth(t *testing.T) {
@ -2281,6 +2376,11 @@ func TestClient_auth(t *testing.T) {
name string
authType SMTPAuthType
}{
{"LOGIN via AUTODISCOVER", SMTPAuthAutoDiscover},
{"PLAIN via AUTODISCOVER", SMTPAuthAutoDiscover},
{"SCRAM-SHA-1 via AUTODISCOVER", SMTPAuthAutoDiscover},
{"SCRAM-SHA-256 via AUTODISCOVER", SMTPAuthAutoDiscover},
{"XOAUTH2 via AUTODISCOVER", SMTPAuthAutoDiscover},
{"CRAM-MD5", SMTPAuthCramMD5},
{"LOGIN", SMTPAuthLogin},
{"LOGIN-NOENC", SMTPAuthLoginNoEnc},
@ -2486,6 +2586,42 @@ func TestClient_auth(t *testing.T) {
})
}
func TestClient_authTypeAutoDiscover(t *testing.T) {
tests := []struct {
supported string
tls bool
expect SMTPAuthType
shouldFail bool
}{
{"LOGIN SCRAM-SHA-256 SCRAM-SHA-1 SCRAM-SHA-256-PLUS SCRAM-SHA-1-PLUS", true, SMTPAuthSCRAMSHA256PLUS, false},
{"LOGIN SCRAM-SHA-256 SCRAM-SHA-1 SCRAM-SHA-256-PLUS SCRAM-SHA-1-PLUS", false, SMTPAuthSCRAMSHA256, false},
{"LOGIN PLAIN SCRAM-SHA-1 SCRAM-SHA-1-PLUS", true, SMTPAuthSCRAMSHA1PLUS, false},
{"LOGIN PLAIN SCRAM-SHA-1 SCRAM-SHA-1-PLUS", false, SMTPAuthSCRAMSHA1, false},
{"LOGIN XOAUTH2 SCRAM-SHA-1-PLUS", false, SMTPAuthXOAUTH2, false},
{"PLAIN LOGIN CRAM-MD5", false, SMTPAuthCramMD5, false},
{"CRAM-MD5", false, SMTPAuthCramMD5, false},
{"PLAIN", true, SMTPAuthPlain, false},
{"LOGIN PLAIN", true, SMTPAuthPlain, false},
{"LOGIN PLAIN", false, "no secure mechanism", true},
{"", false, "supported list empty", true},
}
for _, tt := range tests {
t.Run("AutoDiscover selects the strongest auth type: "+string(tt.expect), func(t *testing.T) {
client := &Client{smtpAuthType: SMTPAuthAutoDiscover}
authType, err := client.authTypeAutoDiscover(tt.supported, tt.tls)
if err != nil && !tt.shouldFail {
t.Fatalf("failed to auto discover auth type: %s", err)
}
if tt.shouldFail && err == nil {
t.Fatal("expected auto discover to fail")
}
if !tt.shouldFail && authType != tt.expect {
t.Errorf("expected strongest auth type: %s, got: %s", tt.expect, authType)
}
})
}
}
func TestClient_Send(t *testing.T) {
message := testMessage(t)
t.Run("connect and send email", func(t *testing.T) {
@ -2606,6 +2742,82 @@ func TestClient_Send(t *testing.T) {
})
}
func TestClient_DialToSMTPClientWithContext(t *testing.T) {
t.Run("establish a new client connection", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort,
}); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500)
t.Cleanup(cancelDial)
client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS))
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
smtpClient, err := client.DialToSMTPClientWithContext(ctxDial)
if err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Skip("failed to connect to the test server due to timeout")
}
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err := client.CloseWithSMTPClient(smtpClient); err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Skip("failed to close the test server connection due to timeout")
}
t.Errorf("failed to close client: %s", err)
}
})
if smtpClient == nil {
t.Fatal("expected SMTP client, got nil")
}
if !smtpClient.HasConnection() {
t.Fatal("expected connection on smtp client")
}
if ok, _ := smtpClient.Extension("DSN"); !ok {
t.Error("expected DSN extension but it was not found")
}
})
t.Run("dial to SMTP server fails on first client writeFile", func(t *testing.T) {
var fake faker
fake.ReadWriter = struct {
io.Reader
io.Writer
}{
failReadWriteSeekCloser{},
failReadWriteSeekCloser{},
}
ctxDial, cancelDial := context.WithTimeout(context.Background(), time.Millisecond*500)
t.Cleanup(cancelDial)
client, err := NewClient(DefaultHost, WithDialContextFunc(getFakeDialFunc(fake)))
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
_, err = client.DialToSMTPClientWithContext(ctxDial)
if err == nil {
t.Fatal("expected connection to fake to fail")
}
})
}
func TestClient_sendSingleMsg(t *testing.T) {
t.Run("connect and send email", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
@ -2645,7 +2857,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err != nil {
if err = client.sendSingleMsg(client.smtpClient, message); err != nil {
t.Errorf("failed to send message: %s", err)
}
})
@ -2688,7 +2900,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
})
@ -2733,7 +2945,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -2783,7 +2995,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -2794,7 +3006,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("expected ErrGetSender, got %s", sendErr.Reason)
}
})
t.Run("fail with no recepient addresses", func(t *testing.T) {
t.Run("fail with no recipient addresses", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
@ -2833,7 +3045,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -2883,7 +3095,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err != nil {
if err = client.sendSingleMsg(client.smtpClient, message); err != nil {
t.Errorf("failed to send message: %s", err)
}
})
@ -2926,7 +3138,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -2977,7 +3189,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -3028,7 +3240,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -3078,7 +3290,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -3128,7 +3340,7 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(message); err == nil {
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Errorf("client should have failed to send message")
}
var sendErr *SendError
@ -3139,6 +3351,59 @@ func TestClient_sendSingleMsg(t *testing.T) {
t.Errorf("expected ErrSMTPDataClose, got %s", sendErr.Reason)
}
})
t.Run("error code and enhanced status code support", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-ENHANCEDSTATUSCODES\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FailOnMailFrom: true,
FeatureSet: featureSet,
ListenPort: serverPort,
}); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
message := testMessage(t)
ctxDial, cancelDial := context.WithTimeout(ctx, time.Millisecond*500)
t.Cleanup(cancelDial)
client, err := NewClient(DefaultHost, WithPort(serverPort), WithTLSPolicy(NoTLS))
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
if err = client.DialWithContext(ctxDial); err != nil {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
t.Skip("failed to connect to the test server due to timeout")
}
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.sendSingleMsg(client.smtpClient, message); err == nil {
t.Error("expected mail delivery to fail")
}
var sendErr *SendError
if !errors.As(err, &sendErr) {
t.Fatalf("expected SendError, got %s", err)
}
if sendErr.errcode != 500 {
t.Errorf("expected error code 500, got %d", sendErr.errcode)
}
if !strings.EqualFold(sendErr.enhancedStatusCode, "5.5.2") {
t.Errorf("expected enhanced status code 5.5.2, got %s", sendErr.enhancedStatusCode)
}
})
}
func TestClient_checkConn(t *testing.T) {
@ -3178,7 +3443,7 @@ func TestClient_checkConn(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.checkConn(); err != nil {
if err = client.checkConn(client.smtpClient); err != nil {
t.Errorf("failed to check connection: %s", err)
}
})
@ -3219,7 +3484,7 @@ func TestClient_checkConn(t *testing.T) {
t.Errorf("failed to close client: %s", err)
}
})
if err = client.checkConn(); err == nil {
if err = client.checkConn(client.smtpClient); err == nil {
t.Errorf("client should have failed on connection check")
}
if !errors.Is(err, ErrNoActiveConnection) {
@ -3231,7 +3496,7 @@ func TestClient_checkConn(t *testing.T) {
if err != nil {
t.Fatalf("failed to create new client: %s", err)
}
if err = client.checkConn(); err == nil {
if err = client.checkConn(client.smtpClient); err == nil {
t.Errorf("client should have failed on connection check")
}
if !errors.Is(err, ErrNoActiveConnection) {
@ -3455,24 +3720,20 @@ func TestClient_XOAuth2OnFaker(t *testing.T) {
}
if err = c.DialWithContext(context.Background()); err == nil {
t.Fatal("expected dial error got nil")
} else {
}
if !errors.Is(err, ErrXOauth2AuthNotSupported) {
t.Fatalf("expected %v; got %v", ErrXOauth2AuthNotSupported, err)
}
}
if err = c.Close(); err != nil {
t.Fatalf("disconnect from test server failed: %v", err)
}
client := strings.Split(wrote.String(), "\r\n")
if len(client) != 3 {
t.Fatalf("unexpected number of client requests got %d; want 3", len(client))
if len(client) != 2 {
t.Fatalf("unexpected number of client requests got %d; want 2", len(client))
}
if !strings.HasPrefix(client[0], "EHLO") {
t.Fatalf("expected EHLO, got %q", client[0])
}
if client[1] != "QUIT" {
t.Fatalf("expected QUIT, got %q", client[3])
}
})
}
@ -3496,6 +3757,17 @@ 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 }
type testSender struct {
client *Client
}
func (t *testSender) Send(ctx context.Context, m *Msg) error {
if err := t.client.DialAndSendWithContext(ctx, m); err != nil {
return fmt.Errorf("failed to dial and send mail: %w", err)
}
return nil
}
// parseJSONLog parses a JSON encoded log from the provided buffer and returns a slice of logLine structs.
// In case of a decode error, it reports the error to the testing framework.
func parseJSONLog(t *testing.T, buf *bytes.Buffer) logData {
@ -3548,6 +3820,8 @@ func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "
// serverProps represents the configuration properties for the SMTP server.
type serverProps struct {
BufferMutex sync.RWMutex
EchoBuffer io.Writer
FailOnAuth bool
FailOnDataInit bool
FailOnDataClose bool
@ -3637,6 +3911,13 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server
if err != nil {
t.Logf("failed to write line: %s", err)
}
if props.EchoBuffer != nil {
props.BufferMutex.Lock()
if _, berr := props.EchoBuffer.Write([]byte(data + "\r\n")); berr != nil {
t.Errorf("failed write to echo buffer: %s", berr)
}
props.BufferMutex.Unlock()
}
_ = writer.Flush()
}
writeOK := func() {
@ -3653,6 +3934,13 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server
break
}
time.Sleep(time.Millisecond)
if props.EchoBuffer != nil {
props.BufferMutex.Lock()
if _, berr := props.EchoBuffer.Write([]byte(data)); berr != nil {
t.Errorf("failed write to echo buffer: %s", berr)
}
props.BufferMutex.Unlock()
}
var datastring string
data = strings.TrimSpace(data)
@ -3713,6 +4001,13 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server
t.Logf("failed to read data from connection: %s", derr)
break
}
if props.EchoBuffer != nil {
props.BufferMutex.Lock()
if _, berr := props.EchoBuffer.Write([]byte(ddata)); berr != nil {
t.Errorf("failed write to echo buffer: %s", berr)
}
props.BufferMutex.Unlock()
}
ddata = strings.TrimSpace(ddata)
if ddata == "." {
if props.FailOnDataClose {

View file

@ -1,19 +1,19 @@
# SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
coverage:
status:
project:
default:
target: 90%
target: 95%
threshold: 2%
base: auto
if_ci_failed: error
only_pulls: false
patch:
default:
target: 90%
target: 95%
base: auto
if_ci_failed: error
threshold: 2%

2
doc.go
View file

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

4
go.mod
View file

@ -7,6 +7,6 @@ module github.com/wneessen/go-mail
go 1.16
require (
golang.org/x/crypto v0.28.0
golang.org/x/text v0.19.0
golang.org/x/crypto v0.30.0
golang.org/x/text v0.21.0
)

14
go.sum
View file

@ -5,8 +5,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
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/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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=
@ -26,7 +26,7 @@ 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/sync v0.10.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=
@ -37,7 +37,7 @@ 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/sys v0.28.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=
@ -46,7 +46,7 @@ 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/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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=
@ -55,8 +55,8 @@ 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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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=

75
msg.go
View file

@ -16,6 +16,7 @@ import (
"fmt"
ht "html/template"
"io"
"io/fs"
"mime"
"net/mail"
"os"
@ -2033,9 +2034,28 @@ func (m *Msg) AttachTextTemplate(
// - 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")
return errors.New("embed.FS must not be nil")
}
file, err := fileFromEmbedFS(name, fs)
return m.AttachFromIOFS(name, *fs, opts...)
}
// AttachFromIOFS attaches a file from a generic file system to the message.
//
// This function retrieves a file by name from an fs.FS instance, processes it, and appends it to the
// message's attachment collection. Additional file options can be provided for further customization.
//
// Parameters:
// - name: The name of the file to retrieve from the file system.
// - iofs: The file system (must not be nil).
// - opts: Optional file options to customize the attachment process.
//
// Returns:
// - An error if the file cannot be retrieved, the fs.FS is nil, or any other issue occurs.
func (m *Msg) AttachFromIOFS(name string, iofs fs.FS, opts ...FileOption) error {
if iofs == nil {
return errors.New("fs.FS must not be nil")
}
file, err := fileFromIOFS(name, iofs)
if err != nil {
return err
}
@ -2179,9 +2199,28 @@ func (m *Msg) EmbedTextTemplate(
// - 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")
return errors.New("embed.FS must not be nil")
}
file, err := fileFromEmbedFS(name, fs)
return m.EmbedFromIOFS(name, *fs, opts...)
}
// EmbedFromIOFS embeds a file from a generic file system into the message.
//
// This function retrieves a file by name from an fs.FS instance, processes it, and appends it to the
// message's embed collection. Additional file options can be provided for further customization.
//
// Parameters:
// - name: The name of the file to retrieve from the file system.
// - iofs: The file system (must not be nil).
// - opts: Optional file options to customize the embedding process.
//
// Returns:
// - An error if the file cannot be retrieved, the fs.FS is nil, or any other issue occurs.
func (m *Msg) EmbedFromIOFS(name string, iofs fs.FS, opts ...FileOption) error {
if iofs == nil {
return errors.New("fs.FS must not be nil")
}
file, err := fileFromIOFS(name, iofs)
if err != nil {
return err
}
@ -2788,15 +2827,15 @@ func (m *Msg) addDefaultHeader() {
m.SetGenHeader(HeaderMIMEVersion, string(m.mimever))
}
// fileFromEmbedFS returns a File pointer from a given file in the provided embed.FS.
// fileFromIOFS returns a File pointer from a given file in the provided fs.FS.
//
// This method retrieves a file from the embedded filesystem (embed.FS) and returns a File structure
// This method retrieves a file from the provided io/fs (fs.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.
// - fs: An instance that satisfies the fs.FS interface
//
// Returns:
// - A pointer to the File structure representing the embedded file.
@ -2804,23 +2843,27 @@ func (m *Msg) addDefaultHeader() {
//
// References:
// - https://datatracker.ietf.org/doc/html/rfc2183
func fileFromEmbedFS(name string, fs *embed.FS) (*File, error) {
_, err := fs.Open(name)
func fileFromIOFS(name string, iofs fs.FS) (*File, error) {
if iofs == nil {
return nil, errors.New("fs.FS is nil")
}
_, err := iofs.Open(name)
if err != nil {
return nil, fmt.Errorf("failed to open file from embed.FS: %w", err)
return nil, fmt.Errorf("failed to open file from fs.FS: %w", err)
}
return &File{
Name: filepath.Base(name),
Header: make(map[string][]string),
Writer: func(writer io.Writer) (int64, error) {
file, err := fs.Open(name)
if err != nil {
return 0, err
file, ferr := iofs.Open(name)
if ferr != nil {
return 0, fmt.Errorf("failed to open file from fs.FS: %w", ferr)
}
numBytes, err := io.Copy(writer, file)
if err != nil {
numBytes, ferr := io.Copy(writer, file)
if ferr != nil {
_ = file.Close()
return numBytes, fmt.Errorf("failed to copy file to io.Writer: %w", err)
return numBytes, fmt.Errorf("failed to copy file from fs.FS to io.Writer: %w", ferr)
}
return numBytes, file.Close()
},

View file

@ -126,8 +126,8 @@ var (
{`" "@domain.tld`, true}, // Still valid, since quoted
{`"<\"@\".!#%$@domain.tld"`, false}, // Quoting with illegal characters is not allowed
{`<\"@\\".!#%$@domain.tld`, false}, // Still a bunch of random illegal characters
{`hi"@"there@domain.tld`, false}, // Quotes must be dot-seperated
{`"<\"@\\".!.#%$@domain.tld`, false}, // Quote is escaped and dot-seperated which would be RFC822 compliant, but not RFC5322 compliant
{`hi"@"there@domain.tld`, false}, // Quotes must be dot-separated
{`"<\"@\\".!.#%$@domain.tld`, false}, // Quote is escaped and dot-separated which would be RFC822 compliant, but not RFC5322 compliant
{`hi\ there@domain.tld`, false}, // Spaces must be quoted
{"hello@tld", true}, // TLD is enough
{`你好@域名.顶级域名`, true}, // We speak RFC6532
@ -4527,12 +4527,12 @@ func TestMsg_AttachFile(t *testing.T) {
t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got)
}
})
t.Run("AttachFile with non-existant file", func(t *testing.T) {
t.Run("AttachFile with non-existent file", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
message.AttachFile("testdata/non-existant-file.txt")
message.AttachFile("testdata/non-existent-file.txt")
attachments := message.GetAttachments()
if len(attachments) != 0 {
t.Fatalf("failed to retrieve attachments list")
@ -4970,6 +4970,75 @@ func TestMsg_AttachFromEmbedFS(t *testing.T) {
})
}
func TestMsg_AttachFromIOFS(t *testing.T) {
t.Run("AttachFromIOFS successful", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
if err := message.AttachFromIOFS("testdata/attachment.txt", efs,
WithFileName("attachment.txt")); err != nil {
t.Fatalf("failed to attach from embed FS: %s", err)
}
attachments := message.GetAttachments()
if len(attachments) != 1 {
t.Fatalf("failed to retrieve attachments list")
}
if attachments[0] == nil {
t.Fatal("expected attachment to be not nil")
}
if attachments[0].Name != "attachment.txt" {
t.Errorf("expected attachment name to be %s, got: %s", "attachment.txt", attachments[0].Name)
}
messageBuf := bytes.NewBuffer(nil)
_, err := attachments[0].Writer(messageBuf)
if err != nil {
t.Errorf("writer func failed: %s", err)
}
got := strings.TrimSpace(messageBuf.String())
if !strings.EqualFold(got, "This is a test attachment") {
t.Errorf("expected message body to be %s, got: %s", "This is a test attachment", got)
}
})
t.Run("AttachFromIOFS with invalid path", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
err := message.AttachFromIOFS("testdata/invalid.txt", efs, WithFileName("attachment.txt"))
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("AttachFromIOFS with nil embed FS", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
err := message.AttachFromIOFS("testdata/invalid.txt", nil, WithFileName("attachment.txt"))
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("AttachFromIOFS with fs.FS fails on copy", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
if err := message.AttachFromIOFS("testdata/attachment.txt", efs); err != nil {
t.Fatalf("failed to attach file from fs.FS: %s", err)
}
attachments := message.GetAttachments()
if len(attachments) != 1 {
t.Fatalf("failed to get attachments, expected 1, got: %d", len(attachments))
}
_, err := attachments[0].Writer(failReadWriteSeekCloser{})
if err == nil {
t.Error("writer func expected to fail, but didn't")
}
})
}
func TestMsg_EmbedFile(t *testing.T) {
t.Run("EmbedFile with file", func(t *testing.T) {
message := NewMsg()
@ -4997,12 +5066,12 @@ func TestMsg_EmbedFile(t *testing.T) {
t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got)
}
})
t.Run("EmbedFile with non-existant file", func(t *testing.T) {
t.Run("EmbedFile with non-existent file", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
message.EmbedFile("testdata/non-existant-file.txt")
message.EmbedFile("testdata/non-existent-file.txt")
embeds := message.GetEmbeds()
if len(embeds) != 0 {
t.Fatalf("failed to retrieve attachments list")
@ -5435,6 +5504,58 @@ func TestMsg_EmbedFromEmbedFS(t *testing.T) {
})
}
func TestMsg_EmbedFromIOFS(t *testing.T) {
t.Run("EmbedFromIOFS successful", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
if err := message.EmbedFromIOFS("testdata/embed.txt", efs,
WithFileName("embed.txt")); err != nil {
t.Fatalf("failed to embed from embed FS: %s", err)
}
embeds := message.GetEmbeds()
if len(embeds) != 1 {
t.Fatalf("failed to retrieve embeds list")
}
if embeds[0] == nil {
t.Fatal("expected embed to be not nil")
}
if embeds[0].Name != "embed.txt" {
t.Errorf("expected embed name to be %s, got: %s", "embed.txt", embeds[0].Name)
}
messageBuf := bytes.NewBuffer(nil)
_, err := embeds[0].Writer(messageBuf)
if err != nil {
t.Errorf("writer func failed: %s", err)
}
got := strings.TrimSpace(messageBuf.String())
if !strings.EqualFold(got, "This is a test embed") {
t.Errorf("expected message body to be %s, got: %s", "This is a test embed", got)
}
})
t.Run("EmbedFromIOFS with invalid path", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
err := message.EmbedFromIOFS("testdata/invalid.txt", efs, WithFileName("embed.txt"))
if err == nil {
t.Fatal("expected error, got nil")
}
})
t.Run("EmbedFromIOFS with nil embed FS", func(t *testing.T) {
message := NewMsg()
if message == nil {
t.Fatal("message is nil")
}
err := message.EmbedFromIOFS("testdata/invalid.txt", nil, WithFileName("embed.txt"))
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestMsg_Reset(t *testing.T) {
message := NewMsg()
if message == nil {
@ -6537,6 +6658,15 @@ func TestMsg_addDefaultHeader(t *testing.T) {
})
}
func TestMsg_fileFromIOFS(t *testing.T) {
t.Run("file from fs.FS where fs is nil ", func(t *testing.T) {
_, err := fileFromIOFS("testfile.txt", nil)
if err == nil {
t.Fatal("expected error for fs.FS that is nil")
}
})
}
// TestSignWithSMime_ValidRSAKeyPair tests WithSMimeSinging with given rsa key pair
func TestSignWithSMime_ValidRSAKeyPair(t *testing.T) {
privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()

View file

@ -281,7 +281,7 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
mimeType = string(file.ContentType)
}
file.setHeader(HeaderContentType, fmt.Sprintf(`%s; name="%s"`, mimeType,
mw.encoder.Encode(mw.charset.String(), file.Name)))
mw.encoder.Encode(mw.charset.String(), sanitizeFilename(file.Name))))
}
if _, ok := file.getHeader(HeaderContentTransferEnc); !ok {
@ -293,7 +293,7 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
if file.Desc != "" {
if _, ok := file.getHeader(HeaderContentDescription); !ok {
file.setHeader(HeaderContentDescription, file.Desc)
file.setHeader(HeaderContentDescription, mw.encoder.Encode(mw.charset.String(), file.Desc))
}
}
@ -303,12 +303,12 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) {
disposition = "attachment"
}
file.setHeader(HeaderContentDisposition, fmt.Sprintf(`%s; filename="%s"`,
disposition, mw.encoder.Encode(mw.charset.String(), file.Name)))
disposition, mw.encoder.Encode(mw.charset.String(), sanitizeFilename(file.Name))))
}
if !isAttachment {
if _, ok := file.getHeader(HeaderContentID); !ok {
file.setHeader(HeaderContentID, fmt.Sprintf("<%s>", file.Name))
file.setHeader(HeaderContentID, fmt.Sprintf("<%s>", sanitizeFilename(file.Name)))
}
}
if mw.depth == 0 {
@ -511,3 +511,33 @@ func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encodin
mw.bytesWritten += n
}
}
// sanitizeFilename sanitizes a given filename string by replacing specific unwanted characters with
// an underscore ('_').
//
// This method replaces any control character and any special character that is problematic for
// MIME headers and file systems with an underscore ('_') character.
//
// The following characters are replaced
// - Any control character (US-ASCII < 32)
// - ", /, :, <, >, ?, \, |, [DEL]
//
// Parameters:
// - input: A string of a filename that is supposed to be sanitized
//
// Returns:
// - A string representing the sanitized version of the filename
func sanitizeFilename(input string) string {
var sanitized strings.Builder
for i := 0; i < len(input); i++ {
// We do not allow control characters in file names.
if input[i] < 32 || input[i] == 34 || input[i] == 47 || input[i] == 58 ||
input[i] == 60 || input[i] == 62 || input[i] == 63 || input[i] == 92 ||
input[i] == 124 || input[i] == 127 {
sanitized.WriteRune('_')
continue
}
sanitized.WriteByte(input[i])
}
return sanitized.String()
}

View file

@ -304,6 +304,65 @@ func TestMsgWriter_addFiles(t *testing.T) {
charset: CharsetUTF8,
encoder: getEncoder(EncodingQP),
}
tests := []struct {
name string
filename string
expect string
}{
{"normal US-ASCII filename", "test.txt", "test.txt"},
{"normal US-ASCII filename with space", "test file.txt", "test file.txt"},
{"filename with new lines", "test\r\n.txt", "test__.txt"},
{"filename with disallowed character:\x22", "test\x22.txt", "test_.txt"},
{"filename with disallowed character:\x2f", "test\x2f.txt", "test_.txt"},
{"filename with disallowed character:\x3a", "test\x3a.txt", "test_.txt"},
{"filename with disallowed character:\x3c", "test\x3c.txt", "test_.txt"},
{"filename with disallowed character:\x3e", "test\x3e.txt", "test_.txt"},
{"filename with disallowed character:\x3f", "test\x3f.txt", "test_.txt"},
{"filename with disallowed character:\x5c", "test\x5c.txt", "test_.txt"},
{"filename with disallowed character:\x7c", "test\x7c.txt", "test_.txt"},
{"filename with disallowed character:\x7f", "test\x7f.txt", "test_.txt"},
{
"japanese characters filename", "添付ファイル.txt",
"=?UTF-8?q?=E6=B7=BB=E4=BB=98=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB.txt?=",
},
{
"simplified chinese characters filename", "测试附件文件.txt",
"=?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=99=84=E4=BB=B6=E6=96=87=E4=BB=B6.txt?=",
},
{
"cyrillic characters filename", "Тестовый прикрепленный файл.txt",
"=?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=D0=BE=D0=B2=D1=8B=D0=B9_=D0=BF=D1=80?= " +
"=?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B?= " +
"=?UTF-8?q?=D0=B9_=D1=84=D0=B0=D0=B9=D0=BB.txt?=",
},
}
for _, tt := range tests {
t.Run("addFile with filename sanitization: "+tt.name, func(t *testing.T) {
buffer := bytes.NewBuffer(nil)
msgwriter.writer = buffer
message := testMessage(t)
message.AttachFile("testdata/attachment.txt", WithFileName(tt.filename))
msgwriter.writeMsg(message)
if msgwriter.err != nil {
t.Errorf("msgWriter failed to write: %s", msgwriter.err)
}
var ctExpect string
cdExpect := fmt.Sprintf(`Content-Disposition: attachment; filename="%s"`, tt.expect)
switch runtime.GOOS {
case "freebsd":
ctExpect = fmt.Sprintf(`Content-Type: application/octet-stream; name="%s"`, tt.expect)
default:
ctExpect = fmt.Sprintf(`Content-Type: text/plain; charset=utf-8; name="%s"`, tt.expect)
}
if !strings.Contains(buffer.String(), ctExpect) {
t.Errorf("expected content-type: %q, got: %q", ctExpect, buffer.String())
}
if !strings.Contains(buffer.String(), cdExpect) {
t.Errorf("expected content-disposition: %q, got: %q", cdExpect, buffer.String())
}
})
}
t.Run("message with a single file attached", func(t *testing.T) {
buffer := bytes.NewBuffer(nil)
msgwriter.writer = buffer
@ -324,7 +383,7 @@ func TestMsgWriter_addFiles(t *testing.T) {
}
}
if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) {
t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String())
t.Errorf("Content-Disposition header not found for attachment. Mail: %s", buffer.String())
}
switch runtime.GOOS {
case "freebsd":
@ -357,7 +416,7 @@ func TestMsgWriter_addFiles(t *testing.T) {
}
}
if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment"`) {
t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String())
t.Errorf("Content-Disposition header not found for attachment. Mail: %s", buffer.String())
}
if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment"`) {
t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String())
@ -383,7 +442,7 @@ func TestMsgWriter_addFiles(t *testing.T) {
}
}
if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) {
t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String())
t.Errorf("Content-Disposition header not found for attachment. Mail: %s", buffer.String())
}
if !strings.Contains(buffer.String(), `Content-Type: application/octet-stream; name="attachment.txt"`) {
t.Errorf("Content-Type header not found for attachment. Mail: %s", buffer.String())
@ -402,7 +461,7 @@ func TestMsgWriter_addFiles(t *testing.T) {
t.Errorf("attachment not found in mail message. Mail: %s", buffer.String())
}
if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) {
t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String())
t.Errorf("Content-Disposition header not found for attachment. Mail: %s", buffer.String())
}
switch runtime.GOOS {
case "freebsd":
@ -438,7 +497,7 @@ func TestMsgWriter_addFiles(t *testing.T) {
}
}
if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) {
t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String())
t.Errorf("Content-Disposition header not found for attachment. Mail: %s", buffer.String())
}
switch runtime.GOOS {
case "freebsd":
@ -478,7 +537,7 @@ func TestMsgWriter_addFiles(t *testing.T) {
}
}
if !strings.Contains(buffer.String(), `Content-Disposition: attachment; filename="attachment.txt"`) {
t.Errorf("Content-Dispositon header not found for attachment. Mail: %s", buffer.String())
t.Errorf("Content-Disposition header not found for attachment. Mail: %s", buffer.String())
}
switch runtime.GOOS {
case "freebsd":
@ -620,7 +679,7 @@ func TestMsgWriter_writeBody(t *testing.T) {
buffer := bytes.NewBuffer(nil)
msgwriter.writer = buffer
message := testMessage(t)
msgwriter.writeBody(message.parts[0].writeFunc, NoEncoding, false)
msgwriter.writeBody(message.parts[0].writeFunc, NoEncoding)
if msgwriter.err != nil {
t.Errorf("writeBody failed to write: %s", msgwriter.err)
}
@ -628,7 +687,7 @@ func TestMsgWriter_writeBody(t *testing.T) {
t.Run("writeBody on NoEncoding fails on write", func(t *testing.T) {
msgwriter.writer = failReadWriteSeekCloser{}
message := testMessage(t)
msgwriter.writeBody(message.parts[0].writeFunc, NoEncoding, false)
msgwriter.writeBody(message.parts[0].writeFunc, NoEncoding)
if msgwriter.err == nil {
t.Errorf("writeBody succeeded, expected error")
}
@ -642,7 +701,7 @@ func TestMsgWriter_writeBody(t *testing.T) {
writeFunc := func(io.Writer) (int64, error) {
return 0, errors.New("intentional write failure")
}
msgwriter.writeBody(writeFunc, NoEncoding, false)
msgwriter.writeBody(writeFunc, NoEncoding)
if msgwriter.err == nil {
t.Errorf("writeBody succeeded, expected error")
}
@ -653,7 +712,7 @@ func TestMsgWriter_writeBody(t *testing.T) {
t.Run("writeBody Quoted-Printable fails on write", func(t *testing.T) {
msgwriter.writer = failReadWriteSeekCloser{}
message := testMessage(t)
msgwriter.writeBody(message.parts[0].writeFunc, EncodingQP, false)
msgwriter.writeBody(message.parts[0].writeFunc, EncodingQP)
if msgwriter.err == nil {
t.Errorf("writeBody succeeded, expected error")
}
@ -677,6 +736,36 @@ func TestMsgWriter_writeBody(t *testing.T) {
})
}
func TestMsgWriter_sanitizeFilename(t *testing.T) {
tests := []struct {
given string
want string
}{
{"test.txt", "test.txt"},
{"test file.txt", "test file.txt"},
{"test\\ file.txt", "test_ file.txt"},
{`"test" file.txt`, "_test_ file.txt"},
{`test file .txt`, "test_file_.txt"},
{"test\r\nfile.txt", "test__file.txt"},
{"test\x22file.txt", "test_file.txt"},
{"test\x2ffile.txt", "test_file.txt"},
{"test\x3afile.txt", "test_file.txt"},
{"test\x3cfile.txt", "test_file.txt"},
{"test\x3efile.txt", "test_file.txt"},
{"test\x3ffile.txt", "test_file.txt"},
{"test\x5cfile.txt", "test_file.txt"},
{"test\x7cfile.txt", "test_file.txt"},
{"test\x7ffile.txt", "test_file.txt"},
}
for _, tt := range tests {
t.Run(tt.given+"=>"+tt.want, func(t *testing.T) {
if got := sanitizeFilename(tt.given); got != tt.want {
t.Errorf("sanitizeFilename failed, expected: %q, got: %q", tt.want, got)
}
})
}
}
// TestMsgWriter_writeMsg_SMime tests the writeMsg method of the msgWriter with S/MIME types set
func TestMsgWriter_writeMsg_SMime(t *testing.T) {
privateKey, certificate, intermediateCertificate, err := getDummyRSACryptoMaterial()
@ -715,3 +804,5 @@ func TestMsgWriter_writeMsg_SMime(t *testing.T) {
t.Errorf("writeMsg failed. Unable to find Content-Type")
}
}

111
quicksend.go Normal file
View file

@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"crypto/tls"
"fmt"
"net"
"strconv"
)
type AuthData struct {
Auth bool
Username string
Password string
}
var testHookTLSConfig func() *tls.Config // nil, except for tests
// QuickSend is an all-in-one method for quickly sending simple text mails in go-mail.
//
// This method will create a new client that connects to the server at addr, switches to TLS if possible,
// authenticates with the optional AuthData provided in auth and create a new simple Msg with the provided
// subject string and message bytes as body. The message will be sent using from as sender address and will
// be delivered to every address in rcpts. QuickSend will always send as text/plain ContentType.
//
// For the SMTP authentication, if auth is not nil and AuthData.Auth is set to true, it will try to
// autodiscover the best SMTP authentication mechanism supported by the server. If auth is set to true
// but autodiscover is not able to find a suitable authentication mechanism or if the authentication
// fails, the mail delivery will fail completely.
//
// The content parameter should be an RFC 822-style email body. The lines of content should be CRLF terminated.
//
// Parameters:
// - addr: The hostname and port of the mail server, it must include a port, as in "mail.example.com:smtp".
// - auth: A AuthData pointer. If nil or if AuthData.Auth is set to false, not SMTP authentication will be performed.
// - from: The from address of the sender as string.
// - rcpts: A slice of strings of receipient addresses.
// - subject: The subject line as string.
// - content: A byte slice of the mail content
//
// Returns:
// - A pointer to the generated Msg.
// - An error if any step in the process of mail generation or delivery failed.
func QuickSend(addr string, auth *AuthData, from string, rcpts []string, subject string, content []byte) (*Msg, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("failed to split host and port from address: %w", err)
}
portnum, err := strconv.Atoi(port)
if err != nil {
return nil, fmt.Errorf("failed to convert port to int: %w", err)
}
client, err := NewClient(host, WithPort(portnum), WithTLSPolicy(TLSOpportunistic))
if err != nil {
return nil, fmt.Errorf("failed to create new client: %w", err)
}
if auth != nil && auth.Auth {
client.SetSMTPAuth(SMTPAuthAutoDiscover)
client.SetUsername(auth.Username)
client.SetPassword(auth.Password)
}
tlsConfig := client.tlsconfig
if testHookTLSConfig != nil {
tlsConfig = testHookTLSConfig()
}
if err = client.SetTLSConfig(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to set TLS config: %w", err)
}
message := NewMsg()
if err = message.From(from); err != nil {
return nil, fmt.Errorf("failed to set MAIL FROM address: %w", err)
}
if err = message.To(rcpts...); err != nil {
return nil, fmt.Errorf("failed to set RCPT TO address: %w", err)
}
message.Subject(subject)
buffer := bytes.NewBuffer(content)
writeFunc := writeFuncFromBuffer(buffer)
message.SetBodyWriter(TypeTextPlain, writeFunc)
if err = client.DialAndSend(message); err != nil {
return nil, fmt.Errorf("failed to dial and send message: %w", err)
}
return message, nil
}
// NewAuthData creates a new AuthData instance with the provided username and password.
//
// This function initializes an AuthData struct with authentication enabled and sets the
// username and password fields.
//
// Parameters:
// - user: The username for authentication.
// - pass: The password for authentication.
//
// Returns:
// - A pointer to the initialized AuthData instance.
func NewAuthData(user, pass string) *AuthData {
return &AuthData{
Auth: true,
Username: user,
Password: pass,
}
}

368
quicksend_test.go Normal file
View file

@ -0,0 +1,368 @@
// SPDX-FileCopyrightText: 2024 The go-mail Authors
//
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"strings"
"testing"
"time"
)
func TestNewAuthData(t *testing.T) {
t.Run("AuthData with username and password", func(t *testing.T) {
auth := NewAuthData("username", "password")
if !auth.Auth {
t.Fatal("expected auth to be true")
}
if auth.Username != "username" {
t.Fatalf("expected username to be %s, got %s", "username", auth.Username)
}
if auth.Password != "password" {
t.Fatalf("expected password to be %s, got %s", "password", auth.Password)
}
})
t.Run("AuthData with username and empty password", func(t *testing.T) {
auth := NewAuthData("username", "")
if !auth.Auth {
t.Fatal("expected auth to be true")
}
if auth.Username != "username" {
t.Fatalf("expected username to be %s, got %s", "username", auth.Username)
}
if auth.Password != "" {
t.Fatalf("expected password to be %s, got %s", "", auth.Password)
}
})
t.Run("AuthData with empty username and set password", func(t *testing.T) {
auth := NewAuthData("", "password")
if !auth.Auth {
t.Fatal("expected auth to be true")
}
if auth.Username != "" {
t.Fatalf("expected username to be %s, got %s", "", auth.Username)
}
if auth.Password != "password" {
t.Fatalf("expected password to be %s, got %s", "password", auth.Password)
}
})
t.Run("AuthData with empty data", func(t *testing.T) {
auth := NewAuthData("", "")
if !auth.Auth {
t.Fatal("expected auth to be true")
}
if auth.Username != "" {
t.Fatalf("expected username to be %s, got %s", "", auth.Username)
}
if auth.Password != "" {
t.Fatalf("expected password to be %s, got %s", "", auth.Password)
}
})
}
func TestQuickSend(t *testing.T) {
subject := "This is a test subject"
body := []byte("This is a test body\r\nWith multiple lines\r\n\r\nBest,\r\n The go-mail team")
sender := TestSenderValid
rcpts := []string{TestRcptValid}
t.Run("QuickSend with authentication and TLS", func(t *testing.T) {
ctxAuth, cancelAuth := context.WithCancel(context.Background())
defer cancelAuth()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8"
echoBuffer := bytes.NewBuffer(nil)
props := &serverProps{
EchoBuffer: echoBuffer,
FeatureSet: featureSet,
ListenPort: serverPort,
}
go func() {
if err := simpleSMTPServer(ctxAuth, t, props); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
addr := TestServerAddr + ":" + fmt.Sprint(serverPort)
testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err != nil {
t.Fatalf("failed to send email: %s", err)
}
props.BufferMutex.RLock()
resp := strings.Split(echoBuffer.String(), "\r\n")
props.BufferMutex.RUnlock()
expects := []struct {
line int
data string
}{
{8, "STARTTLS"},
{17, "AUTH PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk"},
{21, "MAIL FROM:<valid-from@domain.tld> BODY=8BITMIME SMTPUTF8"},
{23, "RCPT TO:<valid-to@domain.tld>"},
{30, "Subject: " + subject},
{33, "From: <valid-from@domain.tld>"},
{34, "To: <valid-to@domain.tld>"},
{35, "Content-Type: text/plain; charset=UTF-8"},
{36, "Content-Transfer-Encoding: quoted-printable"},
{38, "This is a test body"},
{39, "With multiple lines"},
{40, ""},
{41, "Best,"},
{42, " The go-mail team"},
}
for _, expect := range expects {
if !strings.EqualFold(resp[expect.line], expect.data) {
t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line])
}
}
})
t.Run("QuickSend with authentication and TLS and multiple receipients", func(t *testing.T) {
ctxAuth, cancelAuth := context.WithCancel(context.Background())
defer cancelAuth()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8"
echoBuffer := bytes.NewBuffer(nil)
props := &serverProps{
EchoBuffer: echoBuffer,
FeatureSet: featureSet,
ListenPort: serverPort,
}
go func() {
if err := simpleSMTPServer(ctxAuth, t, props); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
addr := TestServerAddr + ":" + fmt.Sprint(serverPort)
testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
multiRcpts := []string{TestRcptValid, TestRcptValid, TestRcptValid}
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, multiRcpts, subject, body)
if err != nil {
t.Fatalf("failed to send email: %s", err)
}
props.BufferMutex.RLock()
resp := strings.Split(echoBuffer.String(), "\r\n")
props.BufferMutex.RUnlock()
expects := []struct {
line int
data string
}{
{8, "STARTTLS"},
{17, "AUTH PLAIN AHVzZXJuYW1lAHBhc3N3b3Jk"},
{21, "MAIL FROM:<valid-from@domain.tld> BODY=8BITMIME SMTPUTF8"},
{23, "RCPT TO:<valid-to@domain.tld>"},
{25, "RCPT TO:<valid-to@domain.tld>"},
{27, "RCPT TO:<valid-to@domain.tld>"},
{34, "Subject: " + subject},
{37, "From: <valid-from@domain.tld>"},
{38, "To: <valid-to@domain.tld>, <valid-to@domain.tld>, <valid-to@domain.tld>"},
{39, "Content-Type: text/plain; charset=UTF-8"},
{40, "Content-Transfer-Encoding: quoted-printable"},
{42, "This is a test body"},
{43, "With multiple lines"},
{44, ""},
{45, "Best,"},
{46, " The go-mail team"},
}
for _, expect := range expects {
if !strings.EqualFold(resp[expect.line], expect.data) {
t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line])
}
}
})
t.Run("QuickSend uses stronged authentication method", func(t *testing.T) {
ctxAuth, cancelAuth := context.WithCancel(context.Background())
defer cancelAuth()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN CRAM-MD5 SCRAM-SHA-256-PLUS SCRAM-SHA-256\r\n250-8BITMIME\r\n250-DSN\r\n250-STARTTLS\r\n250 SMTPUTF8"
echoBuffer := bytes.NewBuffer(nil)
props := &serverProps{
EchoBuffer: echoBuffer,
FeatureSet: featureSet,
ListenPort: serverPort,
}
go func() {
if err := simpleSMTPServer(ctxAuth, t, props); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
addr := TestServerAddr + ":" + fmt.Sprint(serverPort)
testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err != nil {
t.Fatalf("failed to send email: %s", err)
}
props.BufferMutex.RLock()
resp := strings.Split(echoBuffer.String(), "\r\n")
props.BufferMutex.RUnlock()
expects := []struct {
line int
data string
}{
{17, "AUTH SCRAM-SHA-256-PLUS"},
}
for _, expect := range expects {
if !strings.EqualFold(resp[expect.line], expect.data) {
t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line])
}
}
})
t.Run("QuickSend uses stronged authentication method without TLS", func(t *testing.T) {
ctxAuth, cancelAuth := context.WithCancel(context.Background())
defer cancelAuth()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN CRAM-MD5 SCRAM-SHA-256-PLUS SCRAM-SHA-256\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
echoBuffer := bytes.NewBuffer(nil)
props := &serverProps{
EchoBuffer: echoBuffer,
FeatureSet: featureSet,
ListenPort: serverPort,
}
go func() {
if err := simpleSMTPServer(ctxAuth, t, props); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
addr := TestServerAddr + ":" + fmt.Sprint(serverPort)
testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err != nil {
t.Fatalf("failed to send email: %s", err)
}
props.BufferMutex.RLock()
resp := strings.Split(echoBuffer.String(), "\r\n")
props.BufferMutex.RUnlock()
expects := []struct {
line int
data string
}{
{7, "AUTH SCRAM-SHA-256"},
}
for _, expect := range expects {
if !strings.EqualFold(resp[expect.line], expect.data) {
t.Errorf("expected %q at line %d, got: %q", expect.data, expect.line, resp[expect.line])
}
}
})
t.Run("QuickSend fails during DialAndSned", func(t *testing.T) {
ctxAuth, cancelAuth := context.WithCancel(context.Background())
defer cancelAuth()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH PLAIN CRAM-MD5 SCRAM-SHA-256-PLUS SCRAM-SHA-256\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
props := &serverProps{
FailOnMailFrom: true,
FeatureSet: featureSet,
ListenPort: serverPort,
}
go func() {
if err := simpleSMTPServer(ctxAuth, t, props); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
addr := TestServerAddr + ":" + fmt.Sprint(serverPort)
testHookTLSConfig = func() *tls.Config { return &tls.Config{InsecureSkipVerify: true} }
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err == nil {
t.Error("expected QuickSend to fail during DialAndSend")
}
expect := `failed to dial and send message: send failed: sending SMTP MAIL FROM command: 500 ` +
`5.5.2 Error: fail on MAIL FROM`
if !strings.EqualFold(err.Error(), expect) {
t.Errorf("expected error to contain %s, got %s", expect, err)
}
})
t.Run("QuickSend fails on server address without port", func(t *testing.T) {
addr := TestServerAddr
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err == nil {
t.Error("expected QuickSend to fail with invalid server address")
}
expect := "failed to split host and port from address: address 127.0.0.1: missing port in address"
if !strings.Contains(err.Error(), expect) {
t.Errorf("expected error to contain %s, got %s", expect, err)
}
})
t.Run("QuickSend fails on server address with invalid port", func(t *testing.T) {
addr := TestServerAddr + ":invalid"
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err == nil {
t.Error("expected QuickSend to fail with invalid server port")
}
expect := `failed to convert port to int: strconv.Atoi: parsing "invalid": invalid syntax`
if !strings.Contains(err.Error(), expect) {
t.Errorf("expected error to contain %s, got %s", expect, err)
}
})
t.Run("QuickSend fails on nil TLS config (test hook only)", func(t *testing.T) {
addr := TestServerAddr + ":587"
testHookTLSConfig = func() *tls.Config { return nil }
defer func() {
testHookTLSConfig = nil
}()
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, rcpts, subject, body)
if err == nil {
t.Error("expected QuickSend to fail with nil-tlsConfig")
}
expect := `failed to set TLS config: invalid TLS config`
if !strings.Contains(err.Error(), expect) {
t.Errorf("expected error to contain %s, got %s", expect, err)
}
})
t.Run("QuickSend fails with invalid from address", func(t *testing.T) {
addr := TestServerAddr + ":587"
invalid := "invalid-fromdomain.tld"
_, err := QuickSend(addr, NewAuthData("username", "password"), invalid, rcpts, subject, body)
if err == nil {
t.Error("expected QuickSend to fail with invalid from address")
}
expect := `failed to set MAIL FROM address: failed to parse mail address "invalid-fromdomain.tld": ` +
`mail: missing '@' or angle-addr`
if !strings.Contains(err.Error(), expect) {
t.Errorf("expected error to contain %s, got %s", expect, err)
}
})
t.Run("QuickSend fails with invalid from address", func(t *testing.T) {
addr := TestServerAddr + ":587"
invalid := []string{"invalid-todomain.tld"}
_, err := QuickSend(addr, NewAuthData("username", "password"), sender, invalid, subject, body)
if err == nil {
t.Error("expected QuickSend to fail with invalid to address")
}
expect := `failed to set RCPT TO address: failed to parse mail address "invalid-todomain.tld": ` +
`mail: missing '@' or angle-add`
if !strings.Contains(err.Error(), expect) {
t.Errorf("expected error to contain %s, got %s", expect, err)
}
})
}

View file

@ -5,12 +5,15 @@
package mail
import (
"crypto/rand"
"errors"
"strings"
"testing"
)
// TestRandomStringSecure tests the randomStringSecure method
func TestRandomStringSecure(t *testing.T) {
t.Run("randomStringSecure with varying length", func(t *testing.T) {
tt := []struct {
testName string
length int
@ -36,14 +39,47 @@ func TestRandomStringSecure(t *testing.T) {
}
})
}
})
t.Run("randomStringSecure fails on broken rand Reader (first read)", func(t *testing.T) {
defaultRandReader := rand.Reader
t.Cleanup(func() { rand.Reader = defaultRandReader })
rand.Reader = &randReader{failon: 1}
if _, err := randomStringSecure(22); err == nil {
t.Fatalf("expected failure on broken rand Reader")
}
})
t.Run("randomStringSecure fails on broken rand Reader (second read)", func(t *testing.T) {
defaultRandReader := rand.Reader
t.Cleanup(func() { rand.Reader = defaultRandReader })
rand.Reader = &randReader{failon: 0}
if _, err := randomStringSecure(22); err == nil {
t.Fatalf("expected failure on broken rand Reader")
}
})
}
func BenchmarkGenerator_RandomStringSecure(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_, err := randomStringSecure(22)
_, err := randomStringSecure(10)
if err != nil {
b.Errorf("RandomStringFromCharRange() failed: %s", err)
}
}
}
// randReader is type that satisfies the io.Reader interface. It can fail on a specific read
// operations and is therefore useful to test consecutive reads with errors
type randReader struct {
failon uint8
call uint8
}
// Read implements the io.Reader interface for the randReader type
func (r *randReader) Read(p []byte) (int, error) {
if r.call == r.failon {
r.call++
return len(p), nil
}
return 0, errors.New("broken reader")
}

View file

@ -6,6 +6,8 @@ package mail
import (
"errors"
"regexp"
"strconv"
"strings"
)
@ -61,6 +63,8 @@ const (
// the error is temporary or permanent. It also includes a reason code for the error.
type SendError struct {
affectedMsg *Msg
errcode int
enhancedStatusCode string
errlist []error
isTemp bool
rcpt []string
@ -81,7 +85,7 @@ type SendErrReason int
// Returns:
// - A string representing the error message.
func (e *SendError) Error() string {
if e.Reason > 10 {
if e.Reason > ErrAmbiguous {
return "unknown reason"
}
@ -93,7 +97,7 @@ func (e *SendError) Error() string {
errMessage.WriteRune(' ')
errMessage.WriteString(e.errlist[i].Error())
if i != len(e.errlist)-1 {
errMessage.WriteString(", ")
errMessage.WriteString(",")
}
}
}
@ -175,6 +179,42 @@ func (e *SendError) Msg() *Msg {
return e.affectedMsg
}
// EnhancedStatusCode returns the enhanced status code of the server response if the
// server supports it, as described in RFC 2034.
//
// This function retrieves the enhanced status code of an error returned by the server. This
// requires that the receiving server supports this SMTP extension as described in RFC 2034.
// Since this is the SendError interface, we only collect status codes for error responses,
// meaning 4xx or 5xx. If the server does not support the ENHANCEDSTATUSCODES extension or
// the error did not include an enhanced status code, it will return an empty string.
//
// Returns:
// - The enhanced status code as returned by the server, or an empty string is not supported.
//
// References:
// - https://datatracker.ietf.org/doc/html/rfc2034
func (e *SendError) EnhancedStatusCode() string {
if e == nil {
return ""
}
return e.enhancedStatusCode
}
// ErrorCode returns the error code of the server response.
//
// This function retrieves the error code the error returned by the server. The error code will
// start with 5 on permanent errors and with 4 on a temporary error. If the error is not returned
// by the server, but is generated by go-mail, the code will be 0.
//
// Returns:
// - The error code as returned by the server, or 0 if not a server error.
func (e *SendError) ErrorCode() int {
if e == nil {
return 0
}
return e.errcode
}
// String satisfies the fmt.Stringer interface for the SendErrReason type.
//
// This function converts the SendErrReason into a human-readable string representation based
@ -224,3 +264,39 @@ func (r SendErrReason) String() string {
func isTempError(err error) bool {
return err.Error()[0] == '4'
}
func errorCode(err error) int {
rootErr := errors.Unwrap(err)
if rootErr != nil {
err = rootErr
}
firstrune := err.Error()[0]
if firstrune < 52 || firstrune > 53 {
return 0
}
code := err.Error()[0:3]
errcode, cerr := strconv.Atoi(code)
if cerr != nil {
return 0
}
return errcode
}
func enhancedStatusCode(err error, supported bool) string {
if err == nil || !supported {
return ""
}
rootErr := errors.Unwrap(err)
if rootErr != nil {
err = rootErr
}
firstrune := err.Error()[0]
if firstrune != 50 && firstrune != 52 && firstrune != 53 {
return ""
}
re, rerr := regexp.Compile(`\b([245])\.\d{1,3}\.\d{1,3}\b`)
if rerr != nil {
return ""
}
return re.FindString(err.Error())
}

View file

@ -13,10 +13,11 @@ import (
// TestSendError_Error tests the SendError and SendErrReason error handling methods
func TestSendError_Error(t *testing.T) {
tl := []struct {
n string
r SendErrReason
te bool
t.Run("TestSendError_Error with various reasons", func(t *testing.T) {
tests := []struct {
name string
reason SendErrReason
isTemp bool
}{
{"ErrGetSender/temp", ErrGetSender, true},
{"ErrGetSender/perm", ErrGetSender, false},
@ -43,126 +44,323 @@ func TestSendError_Error(t *testing.T) {
{"Unknown/temp", 9999, true},
{"Unknown/perm", 9999, false},
}
for _, tt := range tl {
t.Run(tt.n, func(t *testing.T) {
if err := returnSendError(tt.r, tt.te); err != nil {
exp := &SendError{Reason: tt.r, isTemp: tt.te}
if !errors.Is(err, exp) {
t.Errorf("error mismatch, expected: %s (temp: %t), got: %s (temp: %t)", tt.r, tt.te,
exp.Error(), exp.isTemp)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := returnSendError(tt.reason, tt.isTemp)
if err == nil {
t.Fatalf("error expected, got nil")
}
if !strings.Contains(fmt.Sprintf("%s", err), tt.r.String()) {
want := &SendError{Reason: tt.reason, isTemp: tt.isTemp}
if !errors.Is(err, want) {
t.Errorf("error mismatch, expected: %s (temp: %t), got: %s (temp: %t)",
tt.reason, tt.isTemp, want.Error(), want.isTemp)
}
if !strings.Contains(err.Error(), tt.reason.String()) {
t.Errorf("error string mismatch, expected: %s, got: %s",
tt.r.String(), fmt.Sprintf("%s", err))
}
tt.reason.String(), err.Error())
}
})
}
})
t.Run("TestSendError_Error with multiple errors", func(t *testing.T) {
message := testMessage(t)
err := &SendError{
affectedMsg: message,
errlist: []error{ErrNoRcptAddresses, ErrNoFromAddress},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
}
if !strings.Contains(err.Error(), "ambiguous reason, check Msg.SendError for message specific reasons") {
t.Errorf("error string mismatch, expected: ambiguous reason, check Msg.SendError for message "+
"specific reasons, got: %s", err.Error())
}
if !strings.Contains(err.Error(), "no recipient addresses set, no FROM address set") {
t.Errorf("error string mismatch, expected: no recipient addresses set, no FROM address set, got: %s",
err.Error())
}
if !strings.Contains(err.Error(), "affected recipient(s): <toni.tester@domain.tld>, "+
"<tina.tester@domain.tld>") {
t.Errorf("error string mismatch, expected: affected recipient(s): <toni.tester@domain.tld>, "+
"<tina.tester@domain.tld>, got: %s", err.Error())
}
})
}
func TestSendError_Is(t *testing.T) {
t.Run("TestSendError_Is errors match", func(t *testing.T) {
err1 := returnSendError(ErrAmbiguous, false)
err2 := returnSendError(ErrAmbiguous, false)
if !errors.Is(err1, err2) {
t.Error("error mismatch, expected ErrAmbiguous to be equal to ErrAmbiguous")
}
})
t.Run("TestSendError_Is errors mismatch", func(t *testing.T) {
err1 := returnSendError(ErrAmbiguous, false)
err2 := returnSendError(ErrSMTPMailFrom, false)
if errors.Is(err1, err2) {
t.Error("error mismatch, ErrAmbiguous should not be equal to ErrSMTPMailFrom")
}
})
t.Run("TestSendError_Is on nil", func(t *testing.T) {
var err *SendError
if err.Is(ErrNoFromAddress) {
t.Error("expected false on nil-senderror")
}
})
}
func TestSendError_IsTemp(t *testing.T) {
var se *SendError
err1 := returnSendError(ErrAmbiguous, true)
if !errors.As(err1, &se) {
t.Errorf("error mismatch, expected error to be of type *SendError")
return
t.Run("TestSendError_IsTemp is true", func(t *testing.T) {
err := returnSendError(ErrAmbiguous, true)
if err == nil {
t.Fatalf("error expected, got nil")
}
if errors.As(err1, &se) && !se.IsTemp() {
t.Errorf("error mismatch, expected temporary error")
return
var sendErr *SendError
if !errors.As(err, &sendErr) {
t.Fatal("error expected to be of type *SendError")
}
err2 := returnSendError(ErrAmbiguous, false)
if !errors.As(err2, &se) {
t.Errorf("error mismatch, expected error to be of type *SendError")
return
if !sendErr.IsTemp() {
t.Errorf("expected temporary error, got: temperr: %t", sendErr.IsTemp())
}
if errors.As(err2, &se) && se.IsTemp() {
t.Errorf("error mismatch, expected non-temporary error")
return
})
t.Run("TestSendError_IsTemp is false", func(t *testing.T) {
err := returnSendError(ErrAmbiguous, false)
if err == nil {
t.Fatalf("error expected, got nil")
}
}
func TestSendError_IsTempNil(t *testing.T) {
var sendErr *SendError
if !errors.As(err, &sendErr) {
t.Fatal("error expected to be of type *SendError")
}
if sendErr.IsTemp() {
t.Errorf("expected permanent error, got: temperr: %t", sendErr.IsTemp())
}
})
t.Run("TestSendError_IsTemp is nil", func(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
t.Run("TestSendError_MessageID message ID is set", func(t *testing.T) {
var sendErr *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, &sendErr) {
t.Fatal("error mismatch, expected error to be of type *SendError")
}
if errors.As(err, &se) {
if se.MessageID() == "" {
t.Errorf("sendError expected message-id, but got empty string")
if sendErr.MessageID() == "" {
t.Error("sendError expected message-id, but got empty string")
}
if !strings.EqualFold(se.MessageID(), "<this.is.a.message.id>") {
if !strings.EqualFold(sendErr.MessageID(), "<this.is.a.message.id>") {
t.Errorf("sendError message-id expected: %s, but got: %s", "<this.is.a.message.id>",
se.MessageID())
sendErr.MessageID())
}
})
t.Run("TestSendError_MessageID message ID is not set", func(t *testing.T) {
var sendErr *SendError
message := testMessage(t)
err := &SendError{
affectedMsg: message,
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
}
}
func TestSendError_MessageIDNil(t *testing.T) {
var se *SendError
if se.MessageID() != "" {
t.Error("expected empty string on nil-senderror")
if !errors.As(err, &sendErr) {
t.Fatal("error mismatch, expected error to be of type *SendError")
}
if sendErr.MessageID() != "" {
t.Errorf("sendError expected empty message-id, got: %s", sendErr.MessageID())
}
})
t.Run("TestSendError_MessageID on nil error should return empty", func(t *testing.T) {
var sendErr *SendError
if sendErr.MessageID() != "" {
t.Error("expected empty message-id on nil-senderror")
}
})
}
func TestSendError_Msg(t *testing.T) {
var se *SendError
t.Run("TestSendError_Msg message is set", func(t *testing.T) {
var sendErr *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, &sendErr) {
t.Fatal("error mismatch, expected error to be of type *SendError")
}
if errors.As(err, &se) {
if se.Msg() == nil {
t.Errorf("sendError expected msg pointer, but got nil")
msg := sendErr.Msg()
if msg == nil {
t.Fatalf("sendError expected msg pointer, but got nil")
}
from := se.Msg().GetFromString()
from := msg.GetFromString()
if len(from) == 0 {
t.Errorf("sendError expected msg from, but got empty string")
return
t.Fatal("sendError expected msg from, but got empty string")
}
if !strings.EqualFold(from[0], "<toni.tester@domain.tld>") {
t.Errorf("sendError message from expected: %s, but got: %s", "<toni.tester@domain.tld>",
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): <email1@domain.tld>, <email2@domain.tld>`
})
t.Run("TestSendError_Msg message is not set", func(t *testing.T) {
var sendErr *SendError
err := &SendError{
Reason: ErrAmbiguous, isTemp: false, affectedMsg: nil,
rcpt: []string{"<email1@domain.tld>", "<email2@domain.tld>"},
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
}
if err.Error() != expected {
t.Errorf("error mismatch, expected: %s, got: %s", expected, err.Error())
if !errors.As(err, &sendErr) {
t.Fatal("error mismatch, expected error to be of type *SendError")
}
if sendErr.Msg() != nil {
t.Errorf("sendError expected nil msg pointer, got: %v", sendErr.Msg())
}
})
}
func TestSendError_EnhancedStatusCode(t *testing.T) {
t.Run("SendError with no enhanced status code", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
}
if err.EnhancedStatusCode() != "" {
t.Errorf("expected empty enhanced status code, got: %s", err.EnhancedStatusCode())
}
})
t.Run("SendError with enhanced status code", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
enhancedStatusCode: "5.7.1",
}
if err.EnhancedStatusCode() != "5.7.1" {
t.Errorf("expected enhanced status code: %s, got: %s", "5.7.1", err.EnhancedStatusCode())
}
})
t.Run("enhanced status code on nil error should return empty string", func(t *testing.T) {
var err *SendError
if err.EnhancedStatusCode() != "" {
t.Error("expected empty enhanced status code on nil-senderror")
}
})
}
func TestSendError_ErrorCode(t *testing.T) {
t.Run("ErrorCode with a go-mail error should return 0", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
errcode: errorCode(ErrNoRcptAddresses),
}
if err.ErrorCode() != 0 {
t.Errorf("expected error code: %d, got: %d", 0, err.ErrorCode())
}
})
t.Run("SendError with permanent error", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
errcode: errorCode(errors.New("535 5.7.8 Error: authentication failed")),
}
if err.ErrorCode() != 535 {
t.Errorf("expected error code: %d, got: %d", 535, err.ErrorCode())
}
})
t.Run("SendError with temporary error", func(t *testing.T) {
err := &SendError{
errlist: []error{ErrNoRcptAddresses},
rcpt: []string{"<toni.tester@domain.tld>", "<tina.tester@domain.tld>"},
Reason: ErrAmbiguous,
errcode: errorCode(errors.New("441 4.1.0 Server currently unavailable")),
}
if err.ErrorCode() != 441 {
t.Errorf("expected error code: %d, got: %d", 441, err.ErrorCode())
}
})
t.Run("error code on nil error should return 0", func(t *testing.T) {
var err *SendError
if err.ErrorCode() != 0 {
t.Error("expected 0 error code on nil-senderror")
}
})
}
func TestSendError_errorCode(t *testing.T) {
t.Run("errorCode with a go-mail error should return 0", func(t *testing.T) {
code := errorCode(ErrNoRcptAddresses)
if code != 0 {
t.Errorf("expected error code: %d, got: %d", 0, code)
}
})
t.Run("errorCode with permanent error", func(t *testing.T) {
code := errorCode(errors.New("535 5.7.8 Error: authentication failed"))
if code != 535 {
t.Errorf("expected error code: %d, got: %d", 535, code)
}
})
t.Run("errorCode with temporary error", func(t *testing.T) {
code := errorCode(errors.New("443 4.1.0 Server currently unavailable"))
if code != 443 {
t.Errorf("expected error code: %d, got: %d", 443, code)
}
})
t.Run("errorCode with wrapper error", func(t *testing.T) {
code := errorCode(fmt.Errorf("an error occured: %w", errors.New("443 4.1.0 Server currently unavailable")))
if code != 443 {
t.Errorf("expected error code: %d, got: %d", 443, code)
}
})
t.Run("errorCode with non-4xx and non-5xx error", func(t *testing.T) {
code := errorCode(errors.New("220 2.1.0 This is not an error"))
if code != 0 {
t.Errorf("expected error code: %d, got: %d", 0, code)
}
})
t.Run("errorCode with non 3-digit code", func(t *testing.T) {
code := errorCode(errors.New("4xx 4.1.0 The status code is invalid"))
if code != 0 {
t.Errorf("expected error code: %d, got: %d", 0, code)
}
})
}
func TestSendError_enhancedStatusCode(t *testing.T) {
t.Run("enhancedStatusCode with nil error should return empty string", func(t *testing.T) {
code := enhancedStatusCode(nil, true)
if code != "" {
t.Errorf("expected empty enhanced status code, got: %s", code)
}
})
t.Run("enhancedStatusCode with error but no support should return empty string", func(t *testing.T) {
code := enhancedStatusCode(errors.New("553 5.5.3 something went wrong"), false)
if code != "" {
t.Errorf("expected empty enhanced status code, got: %s", code)
}
})
t.Run("enhancedStatusCode with error and support", func(t *testing.T) {
code := enhancedStatusCode(errors.New("553 5.5.3 something went wrong"), true)
if code != "5.5.3" {
t.Errorf("expected enhanced status code: %s, got: %s", "5.5.3", code)
}
})
t.Run("enhancedStatusCode with wrapped error and support", func(t *testing.T) {
code := enhancedStatusCode(fmt.Errorf("this error is wrapped: %w", errors.New("553 5.5.3 something went wrong")), true)
if code != "5.5.3" {
t.Errorf("expected enhanced status code: %s, got: %s", "5.5.3", code)
}
})
t.Run("enhancedStatusCode with 3xx error", func(t *testing.T) {
code := enhancedStatusCode(errors.New("300 3.0.0 i don't know what i'm doing"), true)
if code != "" {
t.Errorf("expected enhanced status code to be empty, got: %s", code)
}
})
}
// returnSendError is a helper method to retunr a SendError with a specific reason
@ -173,6 +371,5 @@ func returnSendError(r SendErrReason, t bool) error {
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}
}

View file

@ -36,8 +36,8 @@ type loginAuth struct {
// 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, allowUnEnc bool) Auth {
return &loginAuth{username, password, host, 0, allowUnEnc}
func LoginAuth(username, password, host string, allowUnenc bool) Auth {
return &loginAuth{username, password, host, 0, allowUnenc}
}
// Start begins the SMTP authentication process by validating server's TLS status and hostname.

View file

@ -28,8 +28,8 @@ type plainAuth struct {
// PlainAuth 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 PlainAuth(identity, username, password, host string, allowUnEnc bool) Auth {
return &plainAuth{identity, username, password, host, allowUnEnc}
func PlainAuth(identity, username, password, host string, allowUnenc bool) Auth {
return &plainAuth{identity, username, password, host, allowUnenc}
}
func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) {

View file

@ -154,7 +154,7 @@ func (a *scramAuth) initialClientMessage() ([]byte, error) {
connState := a.tlsConnState
bindData := connState.TLSUnique
// crypto/tl: no tls-unique channel binding value for this tls connection, possibly due to missing
// crypto/tls: 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 {
@ -308,10 +308,7 @@ func (a *scramAuth) normalizeUsername() (string, error) {
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 "", fmt.Errorf("failed to normalize string: %w", err)
}
return s, nil
}

View file

@ -587,7 +587,9 @@ func (c *Client) SetLogger(l log.Logger) {
if l == nil {
return
}
c.mutex.Lock()
c.logger = l
c.mutex.Unlock()
}
// SetLogAuthData enables logging of authentication data in the Client.
@ -599,12 +601,16 @@ func (c *Client) SetLogAuthData() {
// SetDSNMailReturnOption sets the DSN mail return option for the Mail method
func (c *Client) SetDSNMailReturnOption(d string) {
c.mutex.Lock()
c.dsnmrtype = d
c.mutex.Unlock()
}
// SetDSNRcptNotifyOption sets the DSN recipient notify option for the Mail method
func (c *Client) SetDSNRcptNotifyOption(d string) {
c.mutex.Lock()
c.dsnrntype = d
c.mutex.Unlock()
}
// HasConnection checks if the client has an active connection.
@ -620,6 +626,9 @@ func (c *Client) HasConnection() bool {
func (c *Client) UpdateDeadline(timeout time.Duration) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.conn == nil {
return errors.New("smtp: client has no connection")
}
if err := c.conn.SetDeadline(time.Now().Add(timeout)); err != nil {
return fmt.Errorf("smtp: failed to update deadline: %w", err)
}

59
smtp/smtp_121_test.go Normal file
View file

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright 2010 The Go Authors. All rights reserved.
// SPDX-FileCopyrightText: Copyright (c) 2022-2023 The go-mail Authors
//
// Original net/smtp code from the Go stdlib by the Go Authors.
// Use of this source code is governed by a BSD-style
// LICENSE file that can be found in this directory.
//
// go-mail specific modifications by the go-mail Authors.
// Licensed under the MIT License.
// See [PROJECT ROOT]/LICENSES directory for more information.
//
// SPDX-License-Identifier: BSD-3-Clause AND MIT
//go:build go1.21
// +build go1.21
package smtp
import (
"fmt"
"os"
"strings"
"testing"
"github.com/wneessen/go-mail/log"
)
func TestClient_SetDebugLog_JSON(t *testing.T) {
t.Run("set debug loggging to on should not override logger", func(t *testing.T) {
client := &Client{logger: log.NewJSON(os.Stderr, log.LevelDebug)}
client.SetDebugLog(true)
if !client.debug {
t.Fatalf("expected debug log to be true")
}
if client.logger == nil {
t.Fatalf("expected logger to be defined")
}
if !strings.EqualFold(fmt.Sprintf("%T", client.logger), "*log.JSONlog") {
t.Errorf("expected logger to be of type *log.JSONlog, got: %T", client.logger)
}
})
}
func TestClient_SetLogger_JSON(t *testing.T) {
t.Run("set logger to JSONlog logger", func(t *testing.T) {
client := &Client{}
client.SetLogger(log.NewJSON(os.Stderr, log.LevelDebug))
if !strings.EqualFold(fmt.Sprintf("%T", client.logger), "*log.JSONlog") {
t.Errorf("expected logger to be of type *log.JSONlog, got: %T", client.logger)
}
})
t.Run("nil logger should just return and not set/override", func(t *testing.T) {
client := &Client{logger: log.NewJSON(os.Stderr, log.LevelDebug)}
client.SetLogger(nil)
if !strings.EqualFold(fmt.Sprintf("%T", client.logger), "*log.JSONlog") {
t.Errorf("expected logger to be of type *log.JSONlog, got: %T", client.logger)
}
})
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
# SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
#
# SPDX-License-Identifier: CC0-1.0
# SPDX-License-Identifier: MIT
sonar.projectKey=go-mail
sonar.go.coverage.reportPaths=cov.out