mirror of
https://github.com/databasus/databasus.git
synced 2026-04-06 00:32:03 +02:00
280 lines
7.3 KiB
Go
280 lines
7.3 KiB
Go
package email_notifier
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"databasus-backend/internal/util/encryption"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/smtp"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
ImplicitTLSPort = 465
|
|
DefaultTimeout = 5 * time.Second
|
|
DefaultHelloName = "localhost"
|
|
MIMETypeHTML = "text/html"
|
|
MIMECharsetUTF8 = "UTF-8"
|
|
)
|
|
|
|
type EmailNotifier struct {
|
|
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;type:uuid;column:notifier_id"`
|
|
TargetEmail string `json:"targetEmail" gorm:"not null;type:varchar(255);column:target_email"`
|
|
SMTPHost string `json:"smtpHost" gorm:"not null;type:varchar(255);column:smtp_host"`
|
|
SMTPPort int `json:"smtpPort" gorm:"not null;column:smtp_port"`
|
|
SMTPUser string `json:"smtpUser" gorm:"type:varchar(255);column:smtp_user"`
|
|
SMTPPassword string `json:"smtpPassword" gorm:"type:varchar(255);column:smtp_password"`
|
|
From string `json:"from" gorm:"type:varchar(255);column:from_email"`
|
|
}
|
|
|
|
func (e *EmailNotifier) TableName() string {
|
|
return "email_notifiers"
|
|
}
|
|
|
|
func (e *EmailNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
|
if e.TargetEmail == "" {
|
|
return errors.New("target email is required")
|
|
}
|
|
|
|
if e.SMTPHost == "" {
|
|
return errors.New("SMTP host is required")
|
|
}
|
|
|
|
if e.SMTPPort == 0 {
|
|
return errors.New("SMTP port is required")
|
|
}
|
|
|
|
// Authentication is optional - both user and password must be provided together or both empty
|
|
if (e.SMTPUser == "") != (e.SMTPPassword == "") {
|
|
return errors.New("SMTP user and password must both be provided or both be empty")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *EmailNotifier) Send(
|
|
encryptor encryption.FieldEncryptor,
|
|
_ *slog.Logger,
|
|
heading string,
|
|
message string,
|
|
) error {
|
|
var smtpPassword string
|
|
if e.SMTPPassword != "" {
|
|
decrypted, err := encryptor.Decrypt(e.NotifierID, e.SMTPPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to decrypt SMTP password: %w", err)
|
|
}
|
|
smtpPassword = decrypted
|
|
}
|
|
|
|
from := e.From
|
|
if from == "" {
|
|
from = e.SMTPUser
|
|
if from == "" {
|
|
from = "noreply@" + e.SMTPHost
|
|
}
|
|
}
|
|
|
|
emailContent := e.buildEmailContent(heading, message, from)
|
|
isAuthRequired := e.SMTPUser != "" && smtpPassword != ""
|
|
|
|
if e.SMTPPort == ImplicitTLSPort {
|
|
return e.sendImplicitTLS(emailContent, from, smtpPassword, isAuthRequired)
|
|
}
|
|
return e.sendStartTLS(emailContent, from, smtpPassword, isAuthRequired)
|
|
}
|
|
|
|
func (e *EmailNotifier) HideSensitiveData() {
|
|
e.SMTPPassword = ""
|
|
}
|
|
|
|
func (e *EmailNotifier) Update(incoming *EmailNotifier) {
|
|
e.TargetEmail = incoming.TargetEmail
|
|
e.SMTPHost = incoming.SMTPHost
|
|
e.SMTPPort = incoming.SMTPPort
|
|
e.SMTPUser = incoming.SMTPUser
|
|
e.From = incoming.From
|
|
|
|
if incoming.SMTPPassword != "" {
|
|
e.SMTPPassword = incoming.SMTPPassword
|
|
}
|
|
}
|
|
|
|
func (e *EmailNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
|
if e.SMTPPassword != "" {
|
|
encrypted, err := encryptor.Encrypt(e.NotifierID, e.SMTPPassword)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt SMTP password: %w", err)
|
|
}
|
|
e.SMTPPassword = encrypted
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *EmailNotifier) buildEmailContent(heading, message, from string) []byte {
|
|
subject := fmt.Sprintf("Subject: %s\r\n", heading)
|
|
mime := fmt.Sprintf(
|
|
"MIME-version: 1.0;\nContent-Type: %s; charset=\"%s\";\n\n",
|
|
MIMETypeHTML,
|
|
MIMECharsetUTF8,
|
|
)
|
|
fromHeader := fmt.Sprintf("From: %s\r\n", from)
|
|
toHeader := fmt.Sprintf("To: %s\r\n", e.TargetEmail)
|
|
return []byte(fromHeader + toHeader + subject + mime + message)
|
|
}
|
|
|
|
func (e *EmailNotifier) sendImplicitTLS(
|
|
emailContent []byte,
|
|
from string,
|
|
password string,
|
|
isAuthRequired bool,
|
|
) error {
|
|
createClient := func() (*smtp.Client, func(), error) {
|
|
return e.createImplicitTLSClient()
|
|
}
|
|
|
|
client, cleanup, err := e.authenticateWithRetry(createClient, password, isAuthRequired)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
return e.sendEmail(client, from, emailContent)
|
|
}
|
|
|
|
func (e *EmailNotifier) sendStartTLS(
|
|
emailContent []byte,
|
|
from string,
|
|
password string,
|
|
isAuthRequired bool,
|
|
) error {
|
|
createClient := func() (*smtp.Client, func(), error) {
|
|
return e.createStartTLSClient()
|
|
}
|
|
|
|
client, cleanup, err := e.authenticateWithRetry(createClient, password, isAuthRequired)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
return e.sendEmail(client, from, emailContent)
|
|
}
|
|
|
|
func (e *EmailNotifier) createImplicitTLSClient() (*smtp.Client, func(), error) {
|
|
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
|
|
tlsConfig := &tls.Config{ServerName: e.SMTPHost}
|
|
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
|
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
|
}
|
|
|
|
client, err := smtp.NewClient(conn, e.SMTPHost)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
|
}
|
|
|
|
return client, func() { _ = client.Quit() }, nil
|
|
}
|
|
|
|
func (e *EmailNotifier) createStartTLSClient() (*smtp.Client, func(), error) {
|
|
addr := net.JoinHostPort(e.SMTPHost, fmt.Sprintf("%d", e.SMTPPort))
|
|
dialer := &net.Dialer{Timeout: DefaultTimeout}
|
|
|
|
conn, err := dialer.Dial("tcp", addr)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
|
|
}
|
|
|
|
client, err := smtp.NewClient(conn, e.SMTPHost)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return nil, nil, fmt.Errorf("failed to create SMTP client: %w", err)
|
|
}
|
|
|
|
if err := client.Hello(DefaultHelloName); err != nil {
|
|
_ = client.Quit()
|
|
_ = conn.Close()
|
|
return nil, nil, fmt.Errorf("SMTP hello failed: %w", err)
|
|
}
|
|
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
if err := client.StartTLS(&tls.Config{ServerName: e.SMTPHost}); err != nil {
|
|
_ = client.Quit()
|
|
_ = conn.Close()
|
|
return nil, nil, fmt.Errorf("STARTTLS failed: %w", err)
|
|
}
|
|
}
|
|
|
|
return client, func() { _ = client.Quit() }, nil
|
|
}
|
|
|
|
func (e *EmailNotifier) authenticateWithRetry(
|
|
createClient func() (*smtp.Client, func(), error),
|
|
password string,
|
|
isAuthRequired bool,
|
|
) (*smtp.Client, func(), error) {
|
|
client, cleanup, err := createClient()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if !isAuthRequired {
|
|
return client, cleanup, nil
|
|
}
|
|
|
|
// Try PLAIN auth first
|
|
plainAuth := smtp.PlainAuth("", e.SMTPUser, password, e.SMTPHost)
|
|
if err := client.Auth(plainAuth); err == nil {
|
|
return client, cleanup, nil
|
|
}
|
|
|
|
// PLAIN auth failed, connection may be closed - recreate and try LOGIN auth
|
|
cleanup()
|
|
|
|
client, cleanup, err = createClient()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
loginAuth := &loginAuth{username: e.SMTPUser, password: password}
|
|
if err := client.Auth(loginAuth); err != nil {
|
|
cleanup()
|
|
return nil, nil, fmt.Errorf("SMTP authentication failed: %w", err)
|
|
}
|
|
|
|
return client, cleanup, nil
|
|
}
|
|
|
|
func (e *EmailNotifier) sendEmail(client *smtp.Client, from string, content []byte) error {
|
|
if err := client.Mail(from); err != nil {
|
|
return fmt.Errorf("failed to set sender: %w", err)
|
|
}
|
|
|
|
if err := client.Rcpt(e.TargetEmail); err != nil {
|
|
return fmt.Errorf("failed to set recipient: %w", err)
|
|
}
|
|
|
|
writer, err := client.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get data writer: %w", err)
|
|
}
|
|
|
|
if _, err = writer.Write(content); err != nil {
|
|
return fmt.Errorf("failed to write email content: %w", err)
|
|
}
|
|
|
|
if err = writer.Close(); err != nil {
|
|
return fmt.Errorf("failed to close data writer: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|