| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 | 
							- // Copyright (C) 2019-2023 Nicola Murino
 
- //
 
- // This program is free software: you can redistribute it and/or modify
 
- // it under the terms of the GNU Affero General Public License as published
 
- // by the Free Software Foundation, version 3.
 
- //
 
- // This program is distributed in the hope that it will be useful,
 
- // but WITHOUT ANY WARRANTY; without even the implied warranty of
 
- // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 
- // GNU Affero General Public License for more details.
 
- //
 
- // You should have received a copy of the GNU Affero General Public License
 
- // along with this program. If not, see <https://www.gnu.org/licenses/>.
 
- // Package smtp provides supports for sending emails
 
- package smtp
 
- import (
 
- 	"bytes"
 
- 	"context"
 
- 	"errors"
 
- 	"fmt"
 
- 	"html/template"
 
- 	"path/filepath"
 
- 	"sync"
 
- 	"time"
 
- 	"github.com/wneessen/go-mail"
 
- 	"github.com/drakkan/sftpgo/v2/internal/dataprovider"
 
- 	"github.com/drakkan/sftpgo/v2/internal/logger"
 
- 	"github.com/drakkan/sftpgo/v2/internal/util"
 
- 	"github.com/drakkan/sftpgo/v2/internal/version"
 
- )
 
- const (
 
- 	logSender = "smtp"
 
- )
 
- // EmailContentType defines the support content types for email body
 
- type EmailContentType int
 
- // Supported email body content type
 
- const (
 
- 	EmailContentTypeTextPlain EmailContentType = iota
 
- 	EmailContentTypeTextHTML
 
- )
 
- const (
 
- 	templateEmailDir           = "email"
 
- 	templatePasswordReset      = "reset-password.html"
 
- 	templatePasswordExpiration = "password-expiration.html"
 
- 	dialTimeout                = 10 * time.Second
 
- )
 
- var (
 
- 	config         = &activeConfig{}
 
- 	initialConfig  *Config
 
- 	emailTemplates = make(map[string]*template.Template)
 
- )
 
- type activeConfig struct {
 
- 	sync.RWMutex
 
- 	config *Config
 
- }
 
- func (c *activeConfig) isEnabled() bool {
 
- 	c.RLock()
 
- 	defer c.RUnlock()
 
- 	return c.config != nil && c.config.Host != ""
 
- }
 
- func (c *activeConfig) Set(cfg *dataprovider.SMTPConfigs) {
 
- 	var config *Config
 
- 	if cfg != nil {
 
- 		config = &Config{
 
- 			Host:       cfg.Host,
 
- 			Port:       cfg.Port,
 
- 			From:       cfg.From,
 
- 			User:       cfg.User,
 
- 			Password:   cfg.Password.GetPayload(),
 
- 			AuthType:   cfg.AuthType,
 
- 			Encryption: cfg.Encryption,
 
- 			Domain:     cfg.Domain,
 
- 		}
 
- 	}
 
- 	c.Lock()
 
- 	defer c.Unlock()
 
- 	if config != nil && config.Host != "" {
 
- 		if c.config != nil && c.config.isEqual(config) {
 
- 			return
 
- 		}
 
- 		c.config = config
 
- 		logger.Info(logSender, "", "activated new config, server %s:%d", c.config.Host, c.config.Port)
 
- 	} else {
 
- 		logger.Debug(logSender, "", "activating initial config")
 
- 		c.config = initialConfig
 
- 		if c.config == nil || c.config.Host == "" {
 
- 			logger.Debug(logSender, "", "configuration disabled, email capabilities will not be available")
 
- 		}
 
- 	}
 
- }
 
- func (c *activeConfig) getSMTPClientAndMsg(to []string, subject, body string, contentType EmailContentType,
 
- 	attachments ...*mail.File,
 
- ) (*mail.Client, *mail.Msg, error) {
 
- 	c.RLock()
 
- 	defer c.RUnlock()
 
- 	if c.config == nil || c.config.Host == "" {
 
- 		return nil, nil, errors.New("smtp: not configured")
 
- 	}
 
- 	return c.config.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
 
- }
 
- func (c *activeConfig) sendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
 
- 	client, msg, err := c.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
 
- 	if err != nil {
 
- 		return err
 
- 	}
 
- 	ctx, cancelFn := context.WithTimeout(context.Background(), dialTimeout)
 
- 	defer cancelFn()
 
- 	return client.DialAndSendWithContext(ctx, msg)
 
- }
 
- // IsEnabled returns true if an SMTP server is configured
 
- func IsEnabled() bool {
 
- 	return config.isEnabled()
 
- }
 
- // Activate sets the specified config as active
 
- func Activate(c *dataprovider.SMTPConfigs) {
 
- 	config.Set(c)
 
- }
 
- // Config defines the SMTP configuration to use to send emails
 
- type Config struct {
 
- 	// Location of SMTP email server. Leavy empty to disable email sending capabilities
 
- 	Host string `json:"host" mapstructure:"host"`
 
- 	// Port of SMTP email server
 
- 	Port int `json:"port" mapstructure:"port"`
 
- 	// From address, for example "SFTPGo <[email protected]>".
 
- 	// Many SMTP servers reject emails without a `From` header so, if not set,
 
- 	// SFTPGo will try to use the username as fallback, this may or may not be appropriate
 
- 	From string `json:"from" mapstructure:"from"`
 
- 	// SMTP username
 
- 	User string `json:"user" mapstructure:"user"`
 
- 	// SMTP password. Leaving both username and password empty the SMTP authentication
 
- 	// will be disabled
 
- 	Password string `json:"password" mapstructure:"password"`
 
- 	// 0 Plain
 
- 	// 1 Login
 
- 	// 2 CRAM-MD5
 
- 	AuthType int `json:"auth_type" mapstructure:"auth_type"`
 
- 	// 0 no encryption
 
- 	// 1 TLS
 
- 	// 2 start TLS
 
- 	Encryption int `json:"encryption" mapstructure:"encryption"`
 
- 	// Domain to use for HELO command, if empty localhost will be used
 
- 	Domain string `json:"domain" mapstructure:"domain"`
 
- 	// Path to the email templates. This can be an absolute path or a path relative to the config dir.
 
- 	// Templates are searched within a subdirectory named "email" in the specified path
 
- 	TemplatesPath string `json:"templates_path" mapstructure:"templates_path"`
 
- }
 
- func (c *Config) isEqual(other *Config) bool {
 
- 	if c.Host != other.Host {
 
- 		return false
 
- 	}
 
- 	if c.Port != other.Port {
 
- 		return false
 
- 	}
 
- 	if c.From != other.From {
 
- 		return false
 
- 	}
 
- 	if c.User != other.User {
 
- 		return false
 
- 	}
 
- 	if c.Password != other.Password {
 
- 		return false
 
- 	}
 
- 	if c.AuthType != other.AuthType {
 
- 		return false
 
- 	}
 
- 	if c.Encryption != other.Encryption {
 
- 		return false
 
- 	}
 
- 	if c.Domain != other.Domain {
 
- 		return false
 
- 	}
 
- 	return true
 
- }
 
- func (c *Config) validate() error {
 
- 	if c.Port <= 0 || c.Port > 65535 {
 
- 		return fmt.Errorf("smtp: invalid port %d", c.Port)
 
- 	}
 
- 	if c.AuthType < 0 || c.AuthType > 2 {
 
- 		return fmt.Errorf("smtp: invalid auth type %d", c.AuthType)
 
- 	}
 
- 	if c.Encryption < 0 || c.Encryption > 2 {
 
- 		return fmt.Errorf("smtp: invalid encryption %d", c.Encryption)
 
- 	}
 
- 	if c.From == "" && c.User == "" {
 
- 		return fmt.Errorf(`smtp: from address and user cannot both be empty`)
 
- 	}
 
- 	return nil
 
- }
 
- func (c *Config) loadTemplates(configDir string) error {
 
- 	if c.TemplatesPath == "" {
 
- 		logger.Debug(logSender, "", "templates path empty, using default")
 
- 		c.TemplatesPath = "templates"
 
- 	}
 
- 	templatesPath := util.FindSharedDataPath(c.TemplatesPath, configDir)
 
- 	if templatesPath == "" {
 
- 		return fmt.Errorf("smtp: invalid templates path %q", templatesPath)
 
- 	}
 
- 	loadTemplates(filepath.Join(templatesPath, templateEmailDir))
 
- 	return nil
 
- }
 
- // Initialize initialized and validates the SMTP configuration
 
- func (c *Config) Initialize(configDir string, isService bool) error {
 
- 	if !isService && c.Host == "" {
 
- 		if err := loadConfigFromProvider(); err != nil {
 
- 			return err
 
- 		}
 
- 		if !config.isEnabled() {
 
- 			return nil
 
- 		}
 
- 		return c.loadTemplates(configDir)
 
- 	}
 
- 	if err := c.loadTemplates(configDir); err != nil {
 
- 		return err
 
- 	}
 
- 	if c.Host == "" {
 
- 		return loadConfigFromProvider()
 
- 	}
 
- 	if err := c.validate(); err != nil {
 
- 		return err
 
- 	}
 
- 	initialConfig = c
 
- 	config.Set(nil)
 
- 	logger.Debug(logSender, "", "configuration successfully initialized, host: %q, port: %d, username: %q, auth: %d, encryption: %d, helo: %q",
 
- 		c.Host, c.Port, c.User, c.AuthType, c.Encryption, c.Domain)
 
- 	return loadConfigFromProvider()
 
- }
 
- func (c *Config) getMailClientOptions() []mail.Option {
 
- 	options := []mail.Option{mail.WithPort(c.Port), mail.WithoutNoop()}
 
- 	switch c.Encryption {
 
- 	case 1:
 
- 		options = append(options, mail.WithSSL())
 
- 	case 2:
 
- 		options = append(options, mail.WithTLSPolicy(mail.TLSMandatory))
 
- 	default:
 
- 		options = append(options, mail.WithTLSPolicy(mail.NoTLS))
 
- 	}
 
- 	if c.User != "" {
 
- 		options = append(options, mail.WithUsername(c.User))
 
- 	}
 
- 	if c.Password != "" {
 
- 		options = append(options, mail.WithPassword(c.Password))
 
- 	}
 
- 	if c.User != "" || c.Password != "" {
 
- 		switch c.AuthType {
 
- 		case 1:
 
- 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthLogin))
 
- 		case 2:
 
- 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthCramMD5))
 
- 		default:
 
- 			options = append(options, mail.WithSMTPAuth(mail.SMTPAuthPlain))
 
- 		}
 
- 	}
 
- 	if c.Domain != "" {
 
- 		options = append(options, mail.WithHELO(c.Domain))
 
- 	}
 
- 	return options
 
- }
 
- func (c *Config) getSMTPClientAndMsg(to []string, subject, body string, contentType EmailContentType,
 
- 	attachments ...*mail.File) (*mail.Client, *mail.Msg, error) {
 
- 	version := version.Get()
 
- 	msg := mail.NewMsg()
 
- 	msg.SetUserAgent(fmt.Sprintf("SFTPGo-%s-%s", version.Version, version.CommitHash))
 
- 	var from string
 
- 	if c.From != "" {
 
- 		from = c.From
 
- 	} else {
 
- 		from = c.User
 
- 	}
 
- 	if err := msg.From(from); err != nil {
 
- 		return nil, nil, fmt.Errorf("invalid from address: %w", err)
 
- 	}
 
- 	if err := msg.To(to...); err != nil {
 
- 		return nil, nil, err
 
- 	}
 
- 	msg.Subject(subject)
 
- 	msg.SetDate()
 
- 	msg.SetMessageID()
 
- 	msg.SetAttachements(attachments)
 
- 	switch contentType {
 
- 	case EmailContentTypeTextPlain:
 
- 		msg.SetBodyString(mail.TypeTextPlain, body)
 
- 	case EmailContentTypeTextHTML:
 
- 		msg.SetBodyString(mail.TypeTextHTML, body)
 
- 	default:
 
- 		return nil, nil, fmt.Errorf("smtp: unsupported body content type %v", contentType)
 
- 	}
 
- 	client, err := mail.NewClient(c.Host, c.getMailClientOptions()...)
 
- 	if err != nil {
 
- 		return nil, nil, fmt.Errorf("unable to create mail client: %w", err)
 
- 	}
 
- 	return client, msg, nil
 
- }
 
- // SendEmail tries to send an email using the specified parameters
 
- func (c *Config) SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
 
- 	client, msg, err := c.getSMTPClientAndMsg(to, subject, body, contentType, attachments...)
 
- 	if err != nil {
 
- 		return err
 
- 	}
 
- 	ctx, cancelFn := context.WithTimeout(context.Background(), dialTimeout)
 
- 	defer cancelFn()
 
- 	return client.DialAndSendWithContext(ctx, msg)
 
- }
 
- func loadTemplates(templatesPath string) {
 
- 	logger.Debug(logSender, "", "loading templates from %q", templatesPath)
 
- 	passwordResetPath := filepath.Join(templatesPath, templatePasswordReset)
 
- 	pwdResetTmpl := util.LoadTemplate(nil, passwordResetPath)
 
- 	passwordExpirationPath := filepath.Join(templatesPath, templatePasswordExpiration)
 
- 	pwdExpirationTmpl := util.LoadTemplate(nil, passwordExpirationPath)
 
- 	emailTemplates[templatePasswordReset] = pwdResetTmpl
 
- 	emailTemplates[templatePasswordExpiration] = pwdExpirationTmpl
 
- }
 
- // RenderPasswordResetTemplate executes the password reset template
 
- func RenderPasswordResetTemplate(buf *bytes.Buffer, data any) error {
 
- 	if !IsEnabled() {
 
- 		return errors.New("smtp: not configured")
 
- 	}
 
- 	return emailTemplates[templatePasswordReset].Execute(buf, data)
 
- }
 
- // RenderPasswordExpirationTemplate executes the password expiration template
 
- func RenderPasswordExpirationTemplate(buf *bytes.Buffer, data any) error {
 
- 	if !IsEnabled() {
 
- 		return errors.New("smtp: not configured")
 
- 	}
 
- 	return emailTemplates[templatePasswordExpiration].Execute(buf, data)
 
- }
 
- // SendEmail tries to send an email using the specified parameters.
 
- func SendEmail(to []string, subject, body string, contentType EmailContentType, attachments ...*mail.File) error {
 
- 	return config.sendEmail(to, subject, body, contentType, attachments...)
 
- }
 
- // ReloadProviderConf reloads the configuration from the provider
 
- // and apply it if different from the active one
 
- func ReloadProviderConf() {
 
- 	loadConfigFromProvider() //nolint:errcheck
 
- }
 
- func loadConfigFromProvider() error {
 
- 	configs, err := dataprovider.GetConfigs()
 
- 	if err != nil {
 
- 		logger.Error(logSender, "", "unable to load config from provider: %v", err)
 
- 		return fmt.Errorf("smtp: unable to load config from provider: %w", err)
 
- 	}
 
- 	configs.SetNilsToEmpty()
 
- 	if err := configs.SMTP.Password.TryDecrypt(); err != nil {
 
- 		logger.Error(logSender, "", "unable to decrypt password: %v", err)
 
- 		return fmt.Errorf("smtp: unable to decrypt password: %w", err)
 
- 	}
 
- 	config.Set(configs.SMTP)
 
- 	return nil
 
- }
 
 
  |