1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

366
pkg/services/smtp/smtp.go Normal file
View file

@ -0,0 +1,366 @@
package smtp
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"net/smtp"
"net/url"
"os"
"strconv"
"time"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
contentHTML = "text/html; charset=\"UTF-8\""
contentPlain = "text/plain; charset=\"UTF-8\""
contentMultipart = "multipart/alternative; boundary=%s"
DefaultSMTPPort = 25 // DefaultSMTPPort is the standard port for SMTP communication.
boundaryByteLen = 8 // boundaryByteLen is the number of bytes for the multipart boundary.
)
// ErrNoAuth is a sentinel error indicating no authentication is required.
var ErrNoAuth = errors.New("no authentication required")
// Service sends notifications to given email addresses via SMTP.
type Service struct {
standard.Standard
standard.Templater
Config *Config
multipartBoundary string
propKeyResolver format.PropKeyResolver
}
// Initialize loads ServiceConfig from configURL and sets logger for this Service.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{
Port: DefaultSMTPPort,
ToAddresses: nil,
Subject: "",
Auth: AuthTypes.Unknown,
UseStartTLS: true,
UseHTML: false,
Encryption: EncMethods.Auto,
ClientHost: "localhost",
}
pkr := format.NewPropKeyResolver(service.Config)
if err := service.Config.setURL(&pkr, configURL); err != nil {
return err
}
if service.Config.Auth == AuthTypes.Unknown {
if service.Config.Username != "" {
service.Config.Auth = AuthTypes.Plain
} else {
service.Config.Auth = AuthTypes.None
}
}
service.propKeyResolver = pkr
return nil
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// Send sends a notification message to email recipients.
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config.Clone()
if err := service.propKeyResolver.UpdateConfigFromParams(&config, params); err != nil {
return fail(FailApplySendParams, err)
}
client, err := getClientConnection(service.Config)
if err != nil {
return fail(FailGetSMTPClient, err)
}
return service.doSend(client, message, &config)
}
// getClientConnection establishes a connection to the SMTP server using the provided configuration.
func getClientConnection(config *Config) (*smtp.Client, error) {
var conn net.Conn
var err error
addr := net.JoinHostPort(config.Host, strconv.FormatUint(uint64(config.Port), 10))
if useImplicitTLS(config.Encryption, config.Port) {
conn, err = tls.Dial("tcp", addr, &tls.Config{
ServerName: config.Host,
MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher
})
} else {
conn, err = net.Dial("tcp", addr)
}
if err != nil {
return nil, fail(FailConnectToServer, err)
}
client, err := smtp.NewClient(conn, config.Host)
if err != nil {
return nil, fail(FailCreateSMTPClient, err)
}
return client, nil
}
// doSend sends an email message using the provided SMTP client and configuration.
func (service *Service) doSend(client *smtp.Client, message string, config *Config) failure {
config.FixEmailTags()
clientHost := service.resolveClientHost(config)
if err := client.Hello(clientHost); err != nil {
return fail(FailHandshake, err)
}
if config.UseHTML {
b := make([]byte, boundaryByteLen)
if _, err := rand.Read(b); err != nil {
return fail(FailUnknown, err) // Fallback error for rare case
}
service.multipartBoundary = hex.EncodeToString(b)
}
if config.UseStartTLS && !useImplicitTLS(config.Encryption, config.Port) {
if supported, _ := client.Extension("StartTLS"); !supported {
service.Logf(
"Warning: StartTLS enabled, but server does not support it. Connection is unencrypted",
)
} else {
if err := client.StartTLS(&tls.Config{
ServerName: config.Host,
MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher
}); err != nil {
return fail(FailEnableStartTLS, err)
}
}
}
if auth, err := service.getAuth(config); err != nil {
return err
} else if auth != nil {
if err := client.Auth(auth); err != nil {
return fail(FailAuthenticating, err)
}
}
for _, toAddress := range config.ToAddresses {
err := service.sendToRecipient(client, toAddress, config, message)
if err != nil {
return fail(FailSendRecipient, err)
}
service.Logf("Mail successfully sent to \"%s\"!\n", toAddress)
}
// Send the QUIT command and close the connection.
err := client.Quit()
if err != nil {
return fail(FailClosingSession, err)
}
return nil
}
// resolveClientHost determines the client hostname to use in the SMTP handshake.
func (service *Service) resolveClientHost(config *Config) string {
if config.ClientHost != "auto" {
return config.ClientHost
}
hostname, err := os.Hostname()
if err != nil {
service.Logf("Failed to get hostname, falling back to localhost: %v", err)
return "localhost"
}
return hostname
}
// getAuth returns the appropriate SMTP authentication mechanism based on the configuration.
//
//nolint:exhaustive,nilnil
func (service *Service) getAuth(config *Config) (smtp.Auth, failure) {
switch config.Auth {
case AuthTypes.None:
return nil, nil // No auth required, proceed without error
case AuthTypes.Plain:
return smtp.PlainAuth("", config.Username, config.Password, config.Host), nil
case AuthTypes.CRAMMD5:
return smtp.CRAMMD5Auth(config.Username, config.Password), nil
case AuthTypes.OAuth2:
return OAuth2Auth(config.Username, config.Password), nil
case AuthTypes.Unknown:
return nil, fail(FailAuthType, nil, config.Auth.String())
default:
return nil, fail(FailAuthType, nil, config.Auth.String())
}
}
// sendToRecipient sends an email to a single recipient using the provided SMTP client.
func (service *Service) sendToRecipient(
client *smtp.Client,
toAddress string,
config *Config,
message string,
) failure {
// Set the sender and recipient first
if err := client.Mail(config.FromAddress); err != nil {
return fail(FailSetSender, err)
}
if err := client.Rcpt(toAddress); err != nil {
return fail(FailSetRecipient, err)
}
// Send the email body.
writeCloser, err := client.Data()
if err != nil {
return fail(FailOpenDataStream, err)
}
if err := writeHeaders(writeCloser, service.getHeaders(toAddress, config.Subject)); err != nil {
return err
}
var ferr failure
if config.UseHTML {
ferr = service.writeMultipartMessage(writeCloser, message)
} else {
ferr = service.writeMessagePart(writeCloser, message, "plain")
}
if ferr != nil {
return ferr
}
if err = writeCloser.Close(); err != nil {
return fail(FailCloseDataStream, err)
}
return nil
}
// getHeaders constructs email headers for the SMTP message.
func (service *Service) getHeaders(toAddress string, subject string) map[string]string {
conf := service.Config
var contentType string
if conf.UseHTML {
contentType = fmt.Sprintf(contentMultipart, service.multipartBoundary)
} else {
contentType = contentPlain
}
return map[string]string{
"Subject": subject,
"Date": time.Now().Format(time.RFC1123Z),
"To": toAddress,
"From": fmt.Sprintf("%s <%s>", conf.FromName, conf.FromAddress),
"MIME-version": "1.0",
"Content-Type": contentType,
}
}
// writeMultipartMessage writes a multipart email message to the provided writer.
func (service *Service) writeMultipartMessage(writeCloser io.WriteCloser, message string) failure {
if err := writeMultipartHeader(writeCloser, service.multipartBoundary, contentPlain); err != nil {
return fail(FailPlainHeader, err)
}
if err := service.writeMessagePart(writeCloser, message, "plain"); err != nil {
return err
}
if err := writeMultipartHeader(writeCloser, service.multipartBoundary, contentHTML); err != nil {
return fail(FailHTMLHeader, err)
}
if err := service.writeMessagePart(writeCloser, message, "HTML"); err != nil {
return err
}
if err := writeMultipartHeader(writeCloser, service.multipartBoundary, ""); err != nil {
return fail(FailMultiEndHeader, err)
}
return nil
}
// writeMessagePart writes a single part of an email message using the specified template.
func (service *Service) writeMessagePart(
writeCloser io.WriteCloser,
message string,
template string,
) failure {
if tpl, found := service.GetTemplate(template); found {
data := make(map[string]string)
data["message"] = message
if err := tpl.Execute(writeCloser, data); err != nil {
return fail(FailMessageTemplate, err)
}
} else {
if _, err := fmt.Fprint(writeCloser, message); err != nil {
return fail(FailMessageRaw, err)
}
}
return nil
}
// writeMultipartHeader writes a multipart boundary header to the provided writer.
func writeMultipartHeader(writeCloser io.WriteCloser, boundary string, contentType string) error {
suffix := "\n"
if len(contentType) < 1 {
suffix = "--"
}
if _, err := fmt.Fprintf(writeCloser, "\n\n--%s%s", boundary, suffix); err != nil {
return fmt.Errorf("writing multipart boundary: %w", err)
}
if len(contentType) > 0 {
if _, err := fmt.Fprintf(writeCloser, "Content-Type: %s\n\n", contentType); err != nil {
return fmt.Errorf("writing content type header: %w", err)
}
}
return nil
}
// writeHeaders writes email headers to the provided writer.
func writeHeaders(writeCloser io.WriteCloser, headers map[string]string) failure {
for key, val := range headers {
if _, err := fmt.Fprintf(writeCloser, "%s: %s\n", key, val); err != nil {
return fail(FailWriteHeaders, err)
}
}
_, err := fmt.Fprintln(writeCloser)
if err != nil {
return fail(FailWriteHeaders, err)
}
return nil
}

View file

@ -0,0 +1,46 @@
package smtp
import (
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
AuthNone authType = iota // 0
AuthPlain // 1
AuthCRAMMD5 // 2
AuthUnknown // 3
AuthOAuth2 // 4
)
// AuthTypes is the enum helper for populating the Auth field.
var AuthTypes = &authTypeVals{
None: AuthNone,
Plain: AuthPlain,
CRAMMD5: AuthCRAMMD5,
Unknown: AuthUnknown,
OAuth2: AuthOAuth2,
Enum: format.CreateEnumFormatter(
[]string{
"None",
"Plain",
"CRAMMD5",
"Unknown",
"OAuth2",
}),
}
type authType int
type authTypeVals struct {
None authType
Plain authType
CRAMMD5 authType
Unknown authType
OAuth2 authType
Enum types.EnumFormatter
}
func (at authType) String() string {
return AuthTypes.Enum.Print(int(at))
}

View file

@ -0,0 +1,120 @@
package smtp
import (
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Scheme is the identifying part of this service's configuration URL.
const Scheme = "smtp"
// Static errors for configuration validation.
var (
ErrFromAddressMissing = errors.New("fromAddress missing from config URL")
ErrToAddressMissing = errors.New("toAddress missing from config URL")
)
// Config is the configuration needed to send e-mail notifications over SMTP.
type Config struct {
Host string `desc:"SMTP server hostname or IP address" url:"Host"`
Username string `desc:"SMTP server username" url:"User" default:""`
Password string `desc:"SMTP server password or hash (for OAuth2)" url:"Pass" default:""`
Port uint16 `desc:"SMTP server port, common ones are 25, 465, 587 or 2525" url:"Port" default:"25"`
FromAddress string `desc:"E-mail address that the mail are sent from" key:"fromaddress,from"`
FromName string `desc:"Name of the sender" key:"fromname" optional:"yes"`
ToAddresses []string `desc:"List of recipient e-mails" key:"toaddresses,to"`
Subject string `desc:"The subject of the sent mail" default:"Shoutrrr Notification" key:"subject,title"`
Auth authType `desc:"SMTP authentication method" default:"Unknown" key:"auth"`
Encryption encMethod `desc:"Encryption method" default:"Auto" key:"encryption"`
UseStartTLS bool `desc:"Whether to use StartTLS encryption" default:"Yes" key:"usestarttls,starttls"`
UseHTML bool `desc:"Whether the message being sent is in HTML" default:"No" key:"usehtml"`
ClientHost string `desc:"SMTP client hostname" default:"localhost" key:"clienthost"`
}
// GetURL returns a URL representation of its current field values.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// SetURL updates a ServiceConfig from a URL representation of its field values.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// getURL constructs a URL from the Config's fields using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
return &url.URL{
User: util.URLUserPassword(config.Username, config.Password),
Host: fmt.Sprintf("%s:%d", config.Host, config.Port),
Path: "/",
Scheme: Scheme,
ForceQuery: true,
RawQuery: format.BuildQuery(resolver),
}
}
// setURL updates the Config from a URL using the provided resolver.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
password, _ := url.User.Password()
config.Username = url.User.Username()
config.Password = password
config.Host = url.Hostname()
if port, err := strconv.ParseUint(url.Port(), 10, 16); err == nil {
config.Port = uint16(port)
}
for key, vals := range url.Query() {
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err)
}
}
if url.String() != "smtp://dummy@dummy.com" {
if len(config.FromAddress) < 1 {
return ErrFromAddressMissing
}
if len(config.ToAddresses) < 1 {
return ErrToAddressMissing
}
}
return nil
}
// Clone returns a copy of the config.
func (config *Config) Clone() Config {
clone := *config
clone.ToAddresses = make([]string, len(config.ToAddresses))
copy(clone.ToAddresses, config.ToAddresses)
return clone
}
// FixEmailTags replaces parsed spaces (+) in e-mail addresses with '+'.
func (config *Config) FixEmailTags() {
config.FromAddress = strings.ReplaceAll(config.FromAddress, " ", "+")
for i, adr := range config.ToAddresses {
config.ToAddresses[i] = strings.ReplaceAll(adr, " ", "+")
}
}
// Enums returns the fields that should use a corresponding EnumFormatter to Print/Parse their values.
func (config *Config) Enums() map[string]types.EnumFormatter {
return map[string]types.EnumFormatter{
"Auth": AuthTypes.Enum,
"Encryption": EncMethods.Enum,
}
}

View file

@ -0,0 +1,71 @@
package smtp
import (
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
const (
// EncNone represents no encryption.
EncNone encMethod = iota // 0
// EncExplicitTLS represents explicit TLS initiated with StartTLS.
EncExplicitTLS // 1
// EncImplicitTLS represents implicit TLS used throughout the session.
EncImplicitTLS // 2
// EncAuto represents automatic TLS selection based on port.
EncAuto // 3
// ImplicitTLSPort is the de facto standard SMTPS port for implicit TLS.
ImplicitTLSPort = 465
)
// EncMethods is the enum helper for populating the Encryption field.
var EncMethods = &encMethodVals{
None: EncNone,
ExplicitTLS: EncExplicitTLS,
ImplicitTLS: EncImplicitTLS,
Auto: EncAuto,
Enum: format.CreateEnumFormatter(
[]string{
"None",
"ExplicitTLS",
"ImplicitTLS",
"Auto",
}),
}
type encMethod int
type encMethodVals struct {
// None means no encryption
None encMethod
// ExplicitTLS means that TLS needs to be initiated by using StartTLS
ExplicitTLS encMethod
// ImplicitTLS means that TLS is used for the whole session
ImplicitTLS encMethod
// Auto means that TLS will be implicitly used for port 465, otherwise explicit TLS will be used if supported
Auto encMethod
// Enum is the EnumFormatter instance for EncMethods
Enum types.EnumFormatter
}
func (at encMethod) String() string {
return EncMethods.Enum.Print(int(at))
}
// useImplicitTLS determines if implicit TLS should be used based on encryption method and port.
func useImplicitTLS(encryption encMethod, port uint16) bool {
switch encryption {
case EncNone:
return false
case EncExplicitTLS:
return false
case EncImplicitTLS:
return true
case EncAuto:
return port == ImplicitTLSPort
default:
return false // Unreachable due to enum constraints, but included for safety
}
}

View file

@ -0,0 +1,104 @@
package smtp
import "github.com/nicholas-fedor/shoutrrr/internal/failures"
const (
// FailUnknown is the default FailureID.
FailUnknown failures.FailureID = iota
// FailGetSMTPClient is returned when a SMTP client could not be created.
FailGetSMTPClient
// FailEnableStartTLS is returned when failing to enable StartTLS.
FailEnableStartTLS
// FailAuthType is returned when the Auth type could not be identified.
FailAuthType
// FailAuthenticating is returned when the authentication fails.
FailAuthenticating
// FailSendRecipient is returned when sending to a recipient fails.
FailSendRecipient
// FailClosingSession is returned when the server doesn't accept the QUIT command.
FailClosingSession
// FailPlainHeader is returned when the text/plain multipart header could not be set.
FailPlainHeader
// FailHTMLHeader is returned when the text/html multipart header could not be set.
FailHTMLHeader
// FailMultiEndHeader is returned when the multipart end header could not be set.
FailMultiEndHeader
// FailMessageTemplate is returned when the message template could not be written to the stream.
FailMessageTemplate
// FailMessageRaw is returned when a non-templated message could not be written to the stream.
FailMessageRaw
// FailSetSender is returned when the server didn't accept the sender address.
FailSetSender
// FailSetRecipient is returned when the server didn't accept the recipient address.
FailSetRecipient
// FailOpenDataStream is returned when the server didn't accept the data stream.
FailOpenDataStream
// FailWriteHeaders is returned when the headers could not be written to the data stream.
FailWriteHeaders
// FailCloseDataStream is returned when the server didn't accept the data stream contents.
FailCloseDataStream
// FailConnectToServer is returned when the TCP connection to the server failed.
FailConnectToServer
// FailCreateSMTPClient is returned when the smtp.Client initialization failed.
FailCreateSMTPClient
// FailApplySendParams is returned when updating the send config failed.
FailApplySendParams
// FailHandshake is returned when the initial HELLO handshake returned an error.
FailHandshake
)
type failure interface {
failures.Failure
}
func fail(failureID failures.FailureID, err error, args ...any) failure {
var msg string
switch failureID {
case FailGetSMTPClient:
msg = "error getting SMTP client"
case FailConnectToServer:
msg = "error connecting to server"
case FailCreateSMTPClient:
msg = "error creating smtp client"
case FailEnableStartTLS:
msg = "error enabling StartTLS"
case FailAuthenticating:
msg = "error authenticating"
case FailAuthType:
msg = "invalid authorization method '%s'"
case FailSendRecipient:
msg = "error sending message to recipient"
case FailClosingSession:
msg = "error closing session"
case FailPlainHeader:
msg = "error writing plain header"
case FailHTMLHeader:
msg = "error writing HTML header"
case FailMultiEndHeader:
msg = "error writing multipart end header"
case FailMessageTemplate:
msg = "error applying message template"
case FailMessageRaw:
msg = "error writing message"
case FailSetSender:
msg = "error creating new message"
case FailSetRecipient:
msg = "error setting RCPT"
case FailOpenDataStream:
msg = "error creating message stream"
case FailWriteHeaders:
msg = "error writing message headers"
case FailCloseDataStream:
msg = "error closing message stream"
case FailApplySendParams:
msg = "error applying params to send config"
case FailHandshake:
msg = "server did not accept the handshake"
// case FailUnknown:
default:
msg = "an unknown error occurred"
}
return failures.Wrap(msg, failureID, err, args...)
}

View file

@ -0,0 +1,25 @@
package smtp
import (
"net/smtp"
)
type oauth2Auth struct {
username, accessToken string
}
// OAuth2Auth returns an Auth that implements the SASL XOAUTH2 authentication
// as per https://developers.google.com/gmail/imap/xoauth2-protocol
func OAuth2Auth(username, accessToken string) smtp.Auth {
return &oauth2Auth{username, accessToken}
}
func (a *oauth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) {
resp := []byte("user=" + a.username + "\x01auth=Bearer " + a.accessToken + "\x01\x01")
return "XOAUTH2", resp, nil
}
func (a *oauth2Auth) Next(_ []byte, _ bool) ([]byte, error) {
return nil, nil
}

View file

@ -0,0 +1,635 @@
package smtp
import (
"log"
"net/smtp"
"net/url"
"os"
"reflect"
"testing"
"unsafe"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
gomegaTypes "github.com/onsi/gomega/types"
"github.com/nicholas-fedor/shoutrrr/internal/failures"
"github.com/nicholas-fedor/shoutrrr/internal/testutils"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var tt *testing.T
func TestSMTP(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
tt = t
ginkgo.RunSpecs(t, "Shoutrrr SMTP Suite")
}
var (
service *Service
envSMTPURL string
logger *log.Logger
_ = ginkgo.BeforeSuite(func() {
envSMTPURL = os.Getenv("SHOUTRRR_SMTP_URL")
logger = testutils.TestLogger()
})
urlWithAllProps = "smtp://user:password@example.com:2225/?auth=None&clienthost=testhost&encryption=ExplicitTLS&fromaddress=sender%40example.com&fromname=Sender&subject=Subject&toaddresses=rec1%40example.com%2Crec2%40example.com&usehtml=Yes&usestarttls=No"
// BaseNoAuthURL is a minimal SMTP config without authentication.
BaseNoAuthURL = "smtp://example.com:2225/?useStartTLS=no&auth=none&fromAddress=sender@example.com&toAddresses=rec1@example.com&useHTML=no"
// BaseAuthURL is a typical config with authentication.
BaseAuthURL = "smtp://user:password@example.com:2225/?useStartTLS=no&fromAddress=sender@example.com&toAddresses=rec1@example.com,rec2@example.com&useHTML=yes"
// BasePlusURL is a config with plus signs in email addresses.
BasePlusURL = "smtp://user:password@example.com:2225/?useStartTLS=no&fromAddress=sender+tag@example.com&toAddresses=rec1+tag@example.com,rec2@example.com&useHTML=yes"
)
// modifyURL modifies a base URL by updating query parameters as specified.
func modifyURL(base string, params map[string]string) string {
u := testutils.URLMust(base)
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
u.RawQuery = q.Encode()
return u.String()
}
var _ = ginkgo.Describe("the SMTP service", func() {
ginkgo.BeforeEach(func() {
service = &Service{}
})
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
url := testutils.URLMust(urlWithAllProps)
config := &Config{}
pkr := format.NewPropKeyResolver(config)
err := config.setURL(&pkr, url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
ginkgo.GinkgoT().Logf("\n\n%s\n%s\n\n-", outputURL, urlWithAllProps)
gomega.Expect(outputURL.String()).To(gomega.Equal(urlWithAllProps))
})
ginkgo.When("resolving client host", func() {
ginkgo.When("clienthost is set to auto", func() {
ginkgo.It("should return the os hostname", func() {
hostname, err := os.Hostname()
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(service.resolveClientHost(&Config{ClientHost: "auto"})).
To(gomega.Equal(hostname))
})
})
ginkgo.When("clienthost is set to a custom value", func() {
ginkgo.It("should return that value", func() {
gomega.Expect(service.resolveClientHost(&Config{ClientHost: "computah"})).
To(gomega.Equal("computah"))
})
})
})
ginkgo.When("fromAddress is missing", func() {
ginkgo.It("should return an error", func() {
testURL := testutils.URLMust(
"smtp://user:password@example.com:2225/?toAddresses=rec1@example.com,rec2@example.com",
)
gomega.Expect((&Config{}).SetURL(testURL)).ToNot(gomega.Succeed())
})
})
ginkgo.When("toAddresses are missing", func() {
ginkgo.It("should return an error", func() {
testURL := testutils.URLMust(
"smtp://user:password@example.com:2225/?fromAddress=sender@example.com",
)
gomega.Expect((&Config{}).SetURL(testURL)).NotTo(gomega.Succeed())
})
})
})
ginkgo.Context("basic service API methods", func() {
var config *Config
ginkgo.BeforeEach(func() {
config = &Config{}
})
ginkgo.It("should not allow getting invalid query values", func() {
testutils.TestConfigGetInvalidQueryValue(config)
})
ginkgo.It("should not allow setting invalid query values", func() {
testutils.TestConfigSetInvalidQueryValue(
config,
"smtp://example.com/?fromAddress=s@example.com&toAddresses=r@example.com&foo=bar",
)
})
ginkgo.It("should have the expected number of fields and enums", func() {
testutils.TestConfigGetEnumsCount(config, 2)
testutils.TestConfigGetFieldsCount(config, 13)
})
})
ginkgo.When("cloning a config", func() {
ginkgo.It("should be identical to the original", func() {
config := &Config{}
gomega.Expect(config.SetURL(testutils.URLMust(urlWithAllProps))).To(gomega.Succeed())
gomega.Expect(config.Clone()).To(gomega.Equal(*config))
})
})
ginkgo.When("sending a message", func() {
ginkgo.When("the service is not configured correctly", func() {
ginkgo.It("should fail to send messages", func() {
service := Service{Config: &Config{}}
gomega.Expect(service.Send("test message", nil)).To(matchFailure(FailGetSMTPClient))
service.Config.Encryption = EncMethods.ImplicitTLS
gomega.Expect(service.Send("test message", nil)).To(matchFailure(FailGetSMTPClient))
})
})
ginkgo.When("an invalid param is passed", func() {
ginkgo.It("should fail to send messages", func() {
service := Service{Config: &Config{}}
gomega.Expect(service.Send("test message", &types.Params{"invalid": "value"})).
To(matchFailure(FailApplySendParams))
})
})
})
ginkgo.When("the underlying stream stops working", func() {
var service Service
var message string
ginkgo.BeforeEach(func() {
service = Service{}
message = ""
})
ginkgo.It("should fail when writing multipart plain header", func() {
writer := testutils.CreateFailWriter(1)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailPlainHeader))
})
ginkgo.It("should fail when writing multipart plain message", func() {
writer := testutils.CreateFailWriter(2)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMessageRaw))
})
ginkgo.It("should fail when writing multipart HTML header", func() {
writer := testutils.CreateFailWriter(4)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailHTMLHeader))
})
ginkgo.It("should fail when writing multipart HTML message", func() {
writer := testutils.CreateFailWriter(5)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMessageRaw))
})
ginkgo.It("should fail when writing multipart end header", func() {
writer := testutils.CreateFailWriter(6)
err := service.writeMultipartMessage(writer, message)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMultiEndHeader))
})
ginkgo.It("should fail when writing message template", func() {
writer := testutils.CreateFailWriter(0)
e := service.SetTemplateString("dummy", "dummy template content")
gomega.Expect(e).ToNot(gomega.HaveOccurred())
err := service.writeMessagePart(writer, message, "dummy")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(err.ID()).To(gomega.Equal(FailMessageTemplate))
})
})
ginkgo.When("running E2E tests", func() {
ginkgo.It("should work without errors", func() {
if envSMTPURL == "" {
ginkgo.Skip("environment not set up for E2E testing")
return
}
serviceURL, err := url.Parse(envSMTPURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = service.Send("this is an integration test", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("running integration tests", func() {
ginkgo.When("given a typical usage case configuration URL", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := BaseAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"235 Accepted",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
}, "<pre>{{ .message }}</pre>", "{{ .message }}")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given e-mail addresses with pluses in the configuration URL", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := BasePlusURL
err := testIntegration(
testURL,
[]string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"235 Accepted",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
},
"<pre>{{ .message }}</pre>", "{{ .message }}",
"RCPT TO:<rec1+tag@example.com>",
"To: rec1+tag@example.com",
"From: <sender+tag@example.com>")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given a configuration URL with authentication disabled", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := BaseNoAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("given a configuration URL with StartTLS but it is not supported", func() {
ginkgo.It("should send notifications without any errors", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"useStartTLS": "yes"})
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"221 OK",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
})
ginkgo.When("server communication fails", func() {
ginkgo.It("should fail when initial handshake is not accepted", func() {
testURL := modifyURL(
BaseNoAuthURL,
map[string]string{"useStartTLS": "yes", "clienthost": "spammer"},
)
err := testIntegration(testURL, []string{
"421 4.7.0 Try again later, closing connection. (EHLO) r20-20020a50d694000000b004588af8956dsm771862edi.9 - gsmtp",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(gomega.MatchError(fail(FailHandshake, nil)))
})
ginkgo.It("should fail when not being able to enable StartTLS", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"useStartTLS": "yes"})
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-STARTTLS",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"502 That's too hard",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailEnableStartTLS))
})
ginkgo.It("should fail when authentication type is invalid", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"auth": "bad"})
err := testIntegration(testURL, []string{}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(standard.FailServiceInit))
})
ginkgo.It("should fail when not being able to use authentication type", func() {
testURL := modifyURL(BaseNoAuthURL, map[string]string{"auth": "crammd5"})
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"504 Liar",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailAuthenticating))
})
ginkgo.It("should fail when not being able to send to recipient", func() {
testURL := BaseNoAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"551 I don't know you",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailSendRecipient))
})
ginkgo.It("should fail when the recipient is not accepted", func() {
testURL := BaseNoAuthURL
err := testSendRecipient(testURL, []string{
"250 mx.google.com at your service",
"250 Sender OK",
"553 She doesn't want to be disturbed",
})
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailSetRecipient))
})
ginkgo.It("should fail when the server does not accept the data stream", func() {
testURL := BaseNoAuthURL
err := testSendRecipient(testURL, []string{
"250 mx.google.com at your service",
"250 Sender OK",
"250 Receiver OK",
"554 Nah I'm fine thanks",
})
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailOpenDataStream))
})
ginkgo.It(
"should fail when the server does not accept the data stream content",
func() {
testURL := BaseNoAuthURL
err := testSendRecipient(testURL, []string{
"250 mx.google.com at your service",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"554 Such garbage!",
})
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailCloseDataStream))
},
)
ginkgo.It(
"should fail when the server does not close the connection gracefully",
func() {
testURL := BaseNoAuthURL
err := testIntegration(testURL, []string{
"250-mx.google.com at your service",
"250-SIZE 35651584",
"250-AUTH LOGIN PLAIN",
"250 8BITMIME",
"250 Sender OK",
"250 Receiver OK",
"354 Go ahead",
"250 Data OK",
"502 You can't quit, you're fired!",
}, "", "")
if msg, test := standard.IsTestSetupFailure(err); test {
ginkgo.Skip(msg)
return
}
gomega.Expect(err).To(matchFailure(FailClosingSession))
},
)
})
})
ginkgo.When("writing headers and the output stream is closed", func() {
ginkgo.When("it's closed during header content", func() {
ginkgo.It("should fail with correct error", func() {
fw := testutils.CreateFailWriter(0)
gomega.Expect(writeHeaders(fw, map[string]string{"key": "value"})).
To(matchFailure(FailWriteHeaders))
})
})
ginkgo.When("it's closed after header content", func() {
ginkgo.It("should fail with correct error", func() {
fw := testutils.CreateFailWriter(1)
gomega.Expect(writeHeaders(fw, map[string]string{"key": "value"})).
To(matchFailure(FailWriteHeaders))
})
})
})
ginkgo.When("default port is not specified", func() {
ginkgo.It("should use the default SMTP port when not specified", func() {
testURL := "smtp://example.com/?fromAddress=sender@example.com&toAddresses=rec1@example.com"
serviceURL := testutils.URLMust(testURL)
err := service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(service.Config.Port).To(gomega.Equal(uint16(DefaultSMTPPort)))
})
})
ginkgo.It("returns the correct service identifier", func() {
gomega.Expect(service.GetID()).To(gomega.Equal("smtp"))
})
})
func testSendRecipient(testURL string, responses []string) failures.Failure {
serviceURL, err := url.Parse(testURL)
if err != nil {
return standard.Failure(standard.FailParseURL, err)
}
err = service.Initialize(serviceURL, logger)
if err != nil {
return failures.Wrap("error parsing URL", standard.FailTestSetup, err)
}
if err := service.SetTemplateString("plain", "{{.message}}"); err != nil {
return failures.Wrap("error setting plain template", standard.FailTestSetup, err)
}
textCon, tcfaker := testutils.CreateTextConFaker(responses, "\r\n")
client := &smtp.Client{
Text: textCon,
}
fakeTLSEnabled(client, serviceURL.Hostname())
config := &Config{}
message := "message body"
ferr := service.sendToRecipient(client, "r@example.com", config, message)
logger.Printf("\n%s", tcfaker.GetConversation(false))
if ferr != nil {
return ferr
}
return nil
}
func testIntegration(
testURL string,
responses []string,
htmlTemplate string,
plainTemplate string,
expectRec ...string,
) failures.Failure {
serviceURL, err := url.Parse(testURL)
if err != nil {
return standard.Failure(standard.FailParseURL, err)
}
if err = service.Initialize(serviceURL, logger); err != nil {
return standard.Failure(standard.FailServiceInit, err)
}
if htmlTemplate != "" {
if err := service.SetTemplateString("HTML", htmlTemplate); err != nil {
return failures.Wrap("error setting HTML template", standard.FailTestSetup, err)
}
}
if plainTemplate != "" {
if err := service.SetTemplateString("plain", plainTemplate); err != nil {
return failures.Wrap("error setting plain template", standard.FailTestSetup, err)
}
}
textCon, tcfaker := testutils.CreateTextConFaker(responses, "\r\n")
client := &smtp.Client{
Text: textCon,
}
fakeTLSEnabled(client, serviceURL.Hostname())
ferr := service.doSend(client, "Test message", service.Config)
received := tcfaker.GetClientSentences()
for _, expected := range expectRec {
gomega.Expect(received).To(gomega.ContainElement(expected))
}
logger.Printf("\n%s", tcfaker.GetConversation(false))
if ferr != nil {
return ferr
}
return nil
}
// fakeTLSEnabled tricks a given client into believing that TLS is enabled even though it's not
// this is needed because the SMTP library won't allow plain authentication without TLS being turned on.
// having it turned on would of course mean that we cannot test the communication since it will be encrypted.
func fakeTLSEnabled(client *smtp.Client, hostname string) {
// set the "tls" flag on the client which indicates that TLS encryption is enabled (even though it's not)
cr := reflect.ValueOf(client).Elem().FieldByName("tls")
cr = reflect.NewAt(cr.Type(), unsafe.Pointer(cr.UnsafeAddr())).Elem()
cr.SetBool(true)
// set the serverName field on the client which is used to identify the server and has to equal the hostname
cr = reflect.ValueOf(client).Elem().FieldByName("serverName")
cr = reflect.NewAt(cr.Type(), unsafe.Pointer(cr.UnsafeAddr())).Elem()
cr.SetString(hostname)
}
// matchFailure is a simple wrapper around `fail` and `gomega.MatchError` to make it easier to use in tests.
func matchFailure(id failures.FailureID) gomegaTypes.GomegaMatcher {
return gomega.MatchError(fail(id, nil))
}