sftpgo/internal/smtp/smtp.go
Nicola Murino a3fff56da5
WebAdmin: add configs section
Setting configurations is an experimental feature and is not currently
supported in the REST API

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
2023-02-19 19:03:45 +01:00

366 lines
11 KiB
Go

// 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"
)
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 <sftpgo@example.com>".
// 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
}
// Initialize initialized and validates the SMTP configuration
func (c *Config) Initialize(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))
if c.Host == "" {
return loadConfigFromProvider()
}
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`)
}
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) {
msg := mail.NewMsg()
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
}