Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
366
pkg/services/smtp/smtp.go
Normal file
366
pkg/services/smtp/smtp.go
Normal 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
|
||||
}
|
46
pkg/services/smtp/smtp_authtype.go
Normal file
46
pkg/services/smtp/smtp_authtype.go
Normal 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))
|
||||
}
|
120
pkg/services/smtp/smtp_config.go
Normal file
120
pkg/services/smtp/smtp_config.go
Normal 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,
|
||||
}
|
||||
}
|
71
pkg/services/smtp/smtp_encmethod.go
Normal file
71
pkg/services/smtp/smtp_encmethod.go
Normal 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
|
||||
}
|
||||
}
|
104
pkg/services/smtp/smtp_failures.go
Normal file
104
pkg/services/smtp/smtp_failures.go
Normal 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...)
|
||||
}
|
25
pkg/services/smtp/smtp_oauth2.go
Normal file
25
pkg/services/smtp/smtp_oauth2.go
Normal 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
|
||||
}
|
635
pkg/services/smtp/smtp_test.go
Normal file
635
pkg/services/smtp/smtp_test.go
Normal 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))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue