package mailer import ( "bytes" "embed" "errors" "fmt" "os" "sync" "time" "github.com/wneessen/go-mail" ht "html/template" tt "text/template" ) //go:embed "templates" var templateFS embed.FS // Mailer renders embedded templates and delivers the resulting message. type Mailer struct { client *mail.Client mode Mode filePath string sender string fileMu sync.Mutex } // Mode selects the delivery backend used by a Mailer. type Mode string const ( // ModeSMTP sends messages through an SMTP server. ModeSMTP Mode = "smtp" // ModeFile appends rendered messages to a local development file. ModeFile Mode = "file" ) // Config contains SMTP or development-file delivery settings. type Config struct { Mode Mode Host string Port int Username string Password string Sender string FilePath string } // New validates config and creates a Mailer. func New(config Config) (*Mailer, error) { mailer := &Mailer{ mode: config.Mode, filePath: config.FilePath, sender: config.Sender, } switch config.Mode { case ModeSMTP: client, err := mail.NewClient( config.Host, mail.WithSMTPAuth(mail.SMTPAuthLogin), mail.WithPort(config.Port), mail.WithUsername(config.Username), mail.WithPassword(config.Password), mail.WithTimeout(5*time.Second), ) if err != nil { return nil, err } mailer.client = client case ModeFile: if config.FilePath == "" { return nil, errors.New("mailer: file path must not be empty in file mode") } default: return nil, fmt.Errorf("mailer: unsupported mode %q", config.Mode) } return mailer, nil } // Send renders templateFile with data and delivers it to recipient. func (m *Mailer) Send(recipient string, templateFile string, data any) error { textTmpl, err := tt.New("").ParseFS(templateFS, "templates/"+templateFile) if err != nil { return err } subject := new(bytes.Buffer) err = textTmpl.ExecuteTemplate(subject, "subject", data) if err != nil { return err } plainBody := new(bytes.Buffer) err = textTmpl.ExecuteTemplate(plainBody, "plainBody", data) if err != nil { return err } htmlTmpl, err := ht.New("").ParseFS(templateFS, "templates/"+templateFile) if err != nil { return err } htmlBody := new(bytes.Buffer) err = htmlTmpl.ExecuteTemplate(htmlBody, "htmlBody", data) if err != nil { return err } msg := mail.NewMsg() err = msg.To(recipient) if err != nil { return err } err = msg.From(m.sender) if err != nil { return err } msg.Subject(subject.String()) msg.SetBodyString(mail.TypeTextPlain, plainBody.String()) msg.AddAlternativeString(mail.TypeTextHTML, htmlBody.String()) if m.mode == ModeFile { return m.appendToFile(msg) } return m.client.DialAndSend(msg) } func (m *Mailer) appendToFile(msg *mail.Msg) error { var content bytes.Buffer if _, err := msg.WriteTo(&content); err != nil { return fmt.Errorf("mailer: format message: %w", err) } m.fileMu.Lock() defer m.fileMu.Unlock() file, err := os.OpenFile(m.filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return fmt.Errorf("mailer: open output file: %w", err) } defer file.Close() if _, err = file.WriteString("\n=== gardomatic mail ===\n"); err != nil { return fmt.Errorf("mailer: append separator: %w", err) } if _, err = content.WriteTo(file); err != nil { return fmt.Errorf("mailer: append message: %w", err) } if _, err = file.WriteString("\n"); err != nil { return fmt.Errorf("mailer: finish message: %w", err) } return nil }