// Package channels provides different communication channels to notify users. package channels import ( "context" "crypto/rand" "crypto/tls" "fmt" stdmail "net/mail" "strings" "time" "github.com/pkg/errors" mail "github.com/xhit/go-simple-mail/v2" "github.com/qsfera/server/pkg/log" "github.com/qsfera/server/services/notifications/pkg/config" ) // Channel defines the methods of a communication channel. type Channel interface { // SendMessage sends a message to users. SendMessage(ctx context.Context, message *Message) error } // Message represent the already rendered message including the user id opaqueID type Message struct { Sender string Recipient []string Subject string TextBody string HTMLBody string AttachInline map[string][]byte } // NewMailChannel instantiates a new mail communication channel. func NewMailChannel(cfg config.Config, logger log.Logger) (Channel, error) { a, err := stdmail.ParseAddress(cfg.Notifications.SMTP.Sender) if err != nil { logger.Err(err).Msg("parsing error, the 'smtp_sender' must be a valid single RFC 5322 address.") return nil, err } return Mail{ conf: cfg, smtpAddress: *a, logger: logger, }, nil } // Mail is the communication channel for email. type Mail struct { conf config.Config smtpAddress stdmail.Address logger log.Logger } func (m Mail) getMailClient() (*mail.SMTPClient, error) { server := mail.NewSMTPClient() server.Host = m.conf.Notifications.SMTP.Host server.Port = m.conf.Notifications.SMTP.Port server.Username = m.conf.Notifications.SMTP.Username if server.Username == "" { // compatibility fallback server.Username = m.conf.Notifications.SMTP.Sender } server.Password = m.conf.Notifications.SMTP.Password if server.TLSConfig == nil { server.TLSConfig = &tls.Config{ MinVersion: tls.VersionTLS12, } } server.TLSConfig.InsecureSkipVerify = m.conf.Notifications.SMTP.Insecure switch strings.ToLower(m.conf.Notifications.SMTP.Authentication) { case "login": server.Authentication = mail.AuthLogin case "plain": server.Authentication = mail.AuthPlain case "crammd5": server.Authentication = mail.AuthCRAMMD5 case "none": server.Authentication = mail.AuthNone case "auto", "": server.Authentication = mail.AuthAuto default: return nil, errors.New("unknown mail authentication method") } switch strings.ToLower(m.conf.Notifications.SMTP.Encryption) { case "starttls": server.Encryption = mail.EncryptionSTARTTLS server.TLSConfig.ServerName = m.conf.Notifications.SMTP.Host case "ssltls": server.Encryption = mail.EncryptionSSLTLS case "none": server.Encryption = mail.EncryptionNone default: return nil, errors.New("unknown mail encryption method") } smtpClient, err := server.Connect() if err != nil { return nil, err } return smtpClient, nil } // SendMessage sends a message to all given users. func (m Mail) SendMessage(_ context.Context, message *Message) error { if m.conf.Notifications.SMTP.Host == "" { m.logger.Info().Str("mail", "SendMessage").Msg("failed to send a message. SMTP host is not set") return nil } smtpClient, err := m.getMailClient() if err != nil { return err } email := mail.NewMSG() email.SetFrom(appendSender(message.Sender, m.smtpAddress)).AddTo(message.Recipient...) email.SetSubject(message.Subject) email.AddHeader("Message-ID", generateMessageID(m.smtpAddress.Address)) email.SetBody(mail.TextPlain, message.TextBody) if message.HTMLBody != "" { email.AddAlternative(mail.TextHTML, message.HTMLBody) for filename, data := range message.AttachInline { email.Attach(&mail.File{Data: data, Name: filename, Inline: true}) } } return email.Send(smtpClient) } func appendSender(sender string, a stdmail.Address) string { if strings.TrimSpace(sender) != "" { a.Name = strings.TrimSpace(sender + " via " + a.Name) } return a.String() } // generateMessageID generates a unique Message-ID header value according to RFC 5322 func generateMessageID(domain string) string { // Extract domain from email address if it contains @ if idx := strings.LastIndex(domain, "@"); idx != -1 { domain = domain[idx+1:] } // Generate random bytes for uniqueness b := make([]byte, 16) if _, err := rand.Read(b); err != nil { // Fallback to timestamp-based ID if random fails return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), domain) } // Create Message-ID: timestamp := time.Now().Unix() return fmt.Sprintf("<%d.%x@%s>", timestamp, b, domain) }