From 2c24c3f2f6acd1a08c0345990f14531e4df4b337 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 6 Oct 2025 14:04:42 +0200 Subject: [PATCH] feat: add SMTP email service with signature reminders Add configurable SMTP service for sending signature reminder emails. Features: - Configurable via ACKIFY_MAIL_* environment variables - Multilingual templates (en/fr) with HTML + text versions - Template rendering with automatic variable injection - Graceful degradation when SMTP not configured - TLS/STARTTLS support with configurable timeout - MailHog integration for local testing --- docker-compose.yml => compose.yml | 0 go.mod | 5 +- go.sum | 6 + internal/infrastructure/config/config.go | 53 ++++ internal/infrastructure/config/config_test.go | 270 ++++++++++++++++++ internal/infrastructure/email/helpers.go | 33 +++ internal/infrastructure/email/renderer.go | 121 ++++++++ internal/infrastructure/email/sender.go | 113 ++++++++ templates/emails/base.html.tmpl | 61 ++++ templates/emails/base.txt.tmpl | 13 + .../emails/signature_reminder.en.html.tmpl | 29 ++ .../emails/signature_reminder.en.txt.tmpl | 21 ++ .../emails/signature_reminder.fr.html.tmpl | 29 ++ .../emails/signature_reminder.fr.txt.tmpl | 21 ++ 14 files changed, 774 insertions(+), 1 deletion(-) rename docker-compose.yml => compose.yml (100%) create mode 100644 internal/infrastructure/email/helpers.go create mode 100644 internal/infrastructure/email/renderer.go create mode 100644 internal/infrastructure/email/sender.go create mode 100644 templates/emails/base.html.tmpl create mode 100644 templates/emails/base.txt.tmpl create mode 100644 templates/emails/signature_reminder.en.html.tmpl create mode 100644 templates/emails/signature_reminder.en.txt.tmpl create mode 100644 templates/emails/signature_reminder.fr.html.tmpl create mode 100644 templates/emails/signature_reminder.fr.txt.tmpl diff --git a/docker-compose.yml b/compose.yml similarity index 100% rename from docker-compose.yml rename to compose.yml diff --git a/go.mod b/go.mod index f966d85..963bf8a 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.24.5 require ( github.com/go-chi/chi/v5 v5.2.3 + github.com/go-mail/mail/v2 v2.3.0 github.com/golang-migrate/migrate/v4 v4.19.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 github.com/lib/pq v1.10.9 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.31.0 + golang.org/x/text v0.29.0 ) require ( @@ -19,7 +21,8 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect - golang.org/x/text v0.29.0 // indirect + gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/mail.v2 v2.3.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2c2e8ce..f18f0f8 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-mail/mail/v2 v2.3.0 h1:wha99yf2v3cpUzD1V9ujP404Jbw2uEvs+rBJybkdYcw= +github.com/go-mail/mail/v2 v2.3.0/go.mod h1:oE2UK8qebZAjjV1ZYUpY7FPnbi/kIU53l1dmqPRb4go= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE= @@ -87,8 +89,12 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= +gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= +gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/infrastructure/config/config.go b/internal/infrastructure/config/config.go index ec2791a..a8e705d 100644 --- a/internal/infrastructure/config/config.go +++ b/internal/infrastructure/config/config.go @@ -16,6 +16,7 @@ type Config struct { OAuth OAuthConfig Server ServerConfig Logger LoggerConfig + Mail MailConfig } type AppConfig struct { @@ -50,6 +51,21 @@ type LoggerConfig struct { Level string } +type MailConfig struct { + Host string + Port int + Username string + Password string + TLS bool + StartTLS bool + Timeout string + From string + FromName string + SubjectPrefix string + TemplateDir string + DefaultLocale string +} + // Load loads configuration from environment variables func Load() (*Config, error) { config := &Config{} @@ -118,6 +134,23 @@ func Load() (*Config, error) { } } + // Parse mail config (optional, service disabled if MAIL_HOST not set) + mailHost := getEnv("ACKIFY_MAIL_HOST", "") + if mailHost != "" { + config.Mail.Host = mailHost + config.Mail.Port = getEnvInt("ACKIFY_MAIL_PORT", 587) + config.Mail.Username = getEnv("ACKIFY_MAIL_USERNAME", "") + config.Mail.Password = getEnv("ACKIFY_MAIL_PASSWORD", "") + config.Mail.TLS = getEnvBool("ACKIFY_MAIL_TLS", true) + config.Mail.StartTLS = getEnvBool("ACKIFY_MAIL_STARTTLS", true) + config.Mail.Timeout = getEnv("ACKIFY_MAIL_TIMEOUT", "10s") + config.Mail.From = getEnv("ACKIFY_MAIL_FROM", "") + config.Mail.FromName = getEnv("ACKIFY_MAIL_FROM_NAME", config.App.Organisation) + config.Mail.SubjectPrefix = getEnv("ACKIFY_MAIL_SUBJECT_PREFIX", "") + config.Mail.TemplateDir = getEnv("ACKIFY_MAIL_TEMPLATE_DIR", "templates/emails") + config.Mail.DefaultLocale = getEnv("ACKIFY_MAIL_DEFAULT_LOCALE", "en") + } + return config, nil } @@ -151,3 +184,23 @@ func parseCookieSecret() ([]byte, error) { return []byte(raw), nil } + +func getEnvInt(key string, defaultValue int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return defaultValue + } + var result int + if _, err := fmt.Sscanf(value, "%d", &result); err == nil { + return result + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return defaultValue + } + return strings.ToLower(value) == "true" || value == "1" +} diff --git a/internal/infrastructure/config/config_test.go b/internal/infrastructure/config/config_test.go index 0dec365..5926038 100644 --- a/internal/infrastructure/config/config_test.go +++ b/internal/infrastructure/config/config_test.go @@ -933,3 +933,273 @@ func equalSlices(a, b []string) bool { } return true } + +func TestLoad_MailConfig(t *testing.T) { + t.Run("mail config with all settings", func(t *testing.T) { + envVars := map[string]string{ + "ACKIFY_BASE_URL": "https://ackify.example.com", + "ACKIFY_ORGANISATION": "Test Org", + "ACKIFY_DB_DSN": "postgres://user:pass@localhost/test", + "ACKIFY_OAUTH_CLIENT_ID": "test-client-id", + "ACKIFY_OAUTH_CLIENT_SECRET": "test-client-secret", + "ACKIFY_OAUTH_PROVIDER": "google", + "ACKIFY_MAIL_HOST": "smtp.example.com", + "ACKIFY_MAIL_PORT": "465", + "ACKIFY_MAIL_USERNAME": "noreply@example.com", + "ACKIFY_MAIL_PASSWORD": "smtp-password", + "ACKIFY_MAIL_TLS": "true", + "ACKIFY_MAIL_STARTTLS": "false", + "ACKIFY_MAIL_TIMEOUT": "30s", + "ACKIFY_MAIL_FROM": "noreply@example.com", + "ACKIFY_MAIL_FROM_NAME": "Ackify Notifications", + "ACKIFY_MAIL_SUBJECT_PREFIX": "[Ackify]", + "ACKIFY_MAIL_TEMPLATE_DIR": "/custom/templates/emails", + "ACKIFY_MAIL_DEFAULT_LOCALE": "fr", + } + + for key, value := range envVars { + _ = os.Setenv(key, value) + } + defer func() { + for key := range envVars { + _ = os.Unsetenv(key) + } + }() + + config, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if config.Mail.Host != "smtp.example.com" { + t.Errorf("Mail.Host = %v, expected smtp.example.com", config.Mail.Host) + } + if config.Mail.Port != 465 { + t.Errorf("Mail.Port = %v, expected 465", config.Mail.Port) + } + if config.Mail.Username != "noreply@example.com" { + t.Errorf("Mail.Username = %v, expected noreply@example.com", config.Mail.Username) + } + if config.Mail.Password != "smtp-password" { + t.Errorf("Mail.Password = %v, expected smtp-password", config.Mail.Password) + } + if !config.Mail.TLS { + t.Error("Mail.TLS should be true") + } + if config.Mail.StartTLS { + t.Error("Mail.StartTLS should be false") + } + if config.Mail.Timeout != "30s" { + t.Errorf("Mail.Timeout = %v, expected 30s", config.Mail.Timeout) + } + if config.Mail.From != "noreply@example.com" { + t.Errorf("Mail.From = %v, expected noreply@example.com", config.Mail.From) + } + if config.Mail.FromName != "Ackify Notifications" { + t.Errorf("Mail.FromName = %v, expected Ackify Notifications", config.Mail.FromName) + } + if config.Mail.SubjectPrefix != "[Ackify]" { + t.Errorf("Mail.SubjectPrefix = %v, expected [Ackify]", config.Mail.SubjectPrefix) + } + if config.Mail.TemplateDir != "/custom/templates/emails" { + t.Errorf("Mail.TemplateDir = %v, expected /custom/templates/emails", config.Mail.TemplateDir) + } + if config.Mail.DefaultLocale != "fr" { + t.Errorf("Mail.DefaultLocale = %v, expected fr", config.Mail.DefaultLocale) + } + }) + + t.Run("mail config with defaults", func(t *testing.T) { + envVars := map[string]string{ + "ACKIFY_BASE_URL": "https://ackify.example.com", + "ACKIFY_ORGANISATION": "Test Org", + "ACKIFY_DB_DSN": "postgres://user:pass@localhost/test", + "ACKIFY_OAUTH_CLIENT_ID": "test-client-id", + "ACKIFY_OAUTH_CLIENT_SECRET": "test-client-secret", + "ACKIFY_OAUTH_PROVIDER": "google", + "ACKIFY_MAIL_HOST": "smtp.example.com", + } + + for key, value := range envVars { + _ = os.Setenv(key, value) + } + defer func() { + for key := range envVars { + _ = os.Unsetenv(key) + } + }() + + config, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if config.Mail.Port != 587 { + t.Errorf("Mail.Port = %v, expected default 587", config.Mail.Port) + } + if !config.Mail.TLS { + t.Error("Mail.TLS should default to true") + } + if !config.Mail.StartTLS { + t.Error("Mail.StartTLS should default to true") + } + if config.Mail.Timeout != "10s" { + t.Errorf("Mail.Timeout = %v, expected default 10s", config.Mail.Timeout) + } + if config.Mail.FromName != "Test Org" { + t.Errorf("Mail.FromName = %v, expected organisation name", config.Mail.FromName) + } + if config.Mail.TemplateDir != "templates/emails" { + t.Errorf("Mail.TemplateDir = %v, expected default templates/emails", config.Mail.TemplateDir) + } + if config.Mail.DefaultLocale != "en" { + t.Errorf("Mail.DefaultLocale = %v, expected default en", config.Mail.DefaultLocale) + } + }) + + t.Run("mail disabled when MAIL_HOST not set", func(t *testing.T) { + envVars := map[string]string{ + "ACKIFY_BASE_URL": "https://ackify.example.com", + "ACKIFY_ORGANISATION": "Test Org", + "ACKIFY_DB_DSN": "postgres://user:pass@localhost/test", + "ACKIFY_OAUTH_CLIENT_ID": "test-client-id", + "ACKIFY_OAUTH_CLIENT_SECRET": "test-client-secret", + "ACKIFY_OAUTH_PROVIDER": "google", + } + + for key, value := range envVars { + _ = os.Setenv(key, value) + } + defer func() { + for key := range envVars { + _ = os.Unsetenv(key) + } + }() + + _ = os.Unsetenv("ACKIFY_MAIL_HOST") + + config, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if config.Mail.Host != "" { + t.Errorf("Mail.Host should be empty when not configured, got %v", config.Mail.Host) + } + if config.Mail.Port != 0 { + t.Errorf("Mail.Port should be 0 when not configured, got %v", config.Mail.Port) + } + }) +} + +func TestGetEnvInt(t *testing.T) { + tests := []struct { + name string + key string + envValue string + defaultValue int + expected int + }{ + { + name: "valid integer", + key: "TEST_INT_VAR", + envValue: "587", + defaultValue: 25, + expected: 587, + }, + { + name: "missing uses default", + key: "MISSING_INT_VAR", + envValue: "", + defaultValue: 100, + expected: 100, + }, + { + name: "invalid integer uses default", + key: "INVALID_INT_VAR", + envValue: "not-a-number", + defaultValue: 50, + expected: 50, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = os.Unsetenv(tt.key) + if tt.envValue != "" { + _ = os.Setenv(tt.key, tt.envValue) + defer func() { + _ = os.Unsetenv(tt.key) + }() + } + + result := getEnvInt(tt.key, tt.defaultValue) + if result != tt.expected { + t.Errorf("getEnvInt() = %v, expected %v", result, tt.expected) + } + }) + } +} + +func TestGetEnvBool(t *testing.T) { + tests := []struct { + name string + key string + envValue string + defaultValue bool + expected bool + }{ + { + name: "true string", + key: "TEST_BOOL_VAR", + envValue: "true", + defaultValue: false, + expected: true, + }, + { + name: "1 string", + key: "TEST_BOOL_VAR", + envValue: "1", + defaultValue: false, + expected: true, + }, + { + name: "false string", + key: "TEST_BOOL_VAR", + envValue: "false", + defaultValue: true, + expected: false, + }, + { + name: "missing uses default true", + key: "MISSING_BOOL_VAR", + envValue: "", + defaultValue: true, + expected: true, + }, + { + name: "missing uses default false", + key: "MISSING_BOOL_VAR", + envValue: "", + defaultValue: false, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = os.Unsetenv(tt.key) + if tt.envValue != "" { + _ = os.Setenv(tt.key, tt.envValue) + defer func() { + _ = os.Unsetenv(tt.key) + }() + } + + result := getEnvBool(tt.key, tt.defaultValue) + if result != tt.expected { + t.Errorf("getEnvBool() = %v, expected %v", result, tt.expected) + } + }) + } +} diff --git a/internal/infrastructure/email/helpers.go b/internal/infrastructure/email/helpers.go new file mode 100644 index 0000000..13b60db --- /dev/null +++ b/internal/infrastructure/email/helpers.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package email + +import ( + "context" +) + +func SendEmail(ctx context.Context, sender Sender, template string, to []string, locale string, subject string, data map[string]any) error { + msg := Message{ + To: to, + Subject: subject, + Template: template, + Locale: locale, + Data: data, + } + + return sender.Send(ctx, msg) +} + +func SendSignatureReminderEmail(ctx context.Context, sender Sender, to []string, locale, docID, docURL, signURL string) error { + data := map[string]any{ + "DocID": docID, + "DocURL": docURL, + "SignURL": signURL, + } + + subject := "Reminder: Document signature required" + if locale == "fr" { + subject = "Rappel : Signature de document requise" + } + + return SendEmail(ctx, sender, "signature_reminder", to, locale, subject, data) +} diff --git a/internal/infrastructure/email/renderer.go b/internal/infrastructure/email/renderer.go new file mode 100644 index 0000000..94719c2 --- /dev/null +++ b/internal/infrastructure/email/renderer.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package email + +import ( + "bytes" + "fmt" + htmlTemplate "html/template" + "os" + "path/filepath" + txtTemplate "text/template" +) + +type Renderer struct { + templateDir string + baseURL string + organisation string + fromName string + fromMail string + defaultLocale string +} + +type TemplateData struct { + Organisation string + BaseURL string + FromName string + FromMail string + Data map[string]any +} + +func NewRenderer(templateDir, baseURL, organisation, fromName, fromMail, defaultLocale string) *Renderer { + return &Renderer{ + templateDir: templateDir, + baseURL: baseURL, + organisation: organisation, + fromName: fromName, + fromMail: fromMail, + defaultLocale: defaultLocale, + } +} + +func (r *Renderer) Render(templateName, locale string, data map[string]any) (htmlBody, textBody string, err error) { + if locale == "" { + locale = r.defaultLocale + } + + templateData := TemplateData{ + Organisation: r.organisation, + BaseURL: r.baseURL, + FromName: r.fromName, + FromMail: r.fromMail, + Data: data, + } + + htmlBody, err = r.renderHTML(templateName, locale, templateData) + if err != nil { + return "", "", fmt.Errorf("failed to render HTML: %w", err) + } + + textBody, err = r.renderText(templateName, locale, templateData) + if err != nil { + return "", "", fmt.Errorf("failed to render text: %w", err) + } + + return htmlBody, textBody, nil +} + +func (r *Renderer) renderHTML(templateName, locale string, data TemplateData) (string, error) { + baseTemplatePath := filepath.Join(r.templateDir, "base.html.tmpl") + templatePath := r.resolveTemplatePath(templateName, locale, "html.tmpl") + + if templatePath == "" { + return "", fmt.Errorf("template not found: %s (locale: %s)", templateName, locale) + } + + tmpl, err := htmlTemplate.ParseFiles(baseTemplatePath, templatePath) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +func (r *Renderer) renderText(templateName, locale string, data TemplateData) (string, error) { + baseTemplatePath := filepath.Join(r.templateDir, "base.txt.tmpl") + templatePath := r.resolveTemplatePath(templateName, locale, "txt.tmpl") + + if templatePath == "" { + return "", fmt.Errorf("template not found: %s (locale: %s)", templateName, locale) + } + + tmpl, err := txtTemplate.ParseFiles(baseTemplatePath, templatePath) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "base", data); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +func (r *Renderer) resolveTemplatePath(templateName, locale, extension string) string { + localizedPath := filepath.Join(r.templateDir, fmt.Sprintf("%s.%s.%s", templateName, locale, extension)) + if _, err := os.Stat(localizedPath); err == nil { + return localizedPath + } + + fallbackPath := filepath.Join(r.templateDir, fmt.Sprintf("%s.en.%s", templateName, extension)) + if _, err := os.Stat(fallbackPath); err == nil { + return fallbackPath + } + + return "" +} diff --git a/internal/infrastructure/email/sender.go b/internal/infrastructure/email/sender.go new file mode 100644 index 0000000..05f5bd5 --- /dev/null +++ b/internal/infrastructure/email/sender.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +package email + +import ( + "context" + "crypto/tls" + "fmt" + "time" + + mail "github.com/go-mail/mail/v2" + + "github.com/btouchard/ackify-ce/internal/infrastructure/config" + "github.com/btouchard/ackify-ce/pkg/logger" +) + +type Sender interface { + Send(ctx context.Context, msg Message) error +} + +type Message struct { + To []string + Cc []string + Bcc []string + Subject string + Template string + Locale string + Data map[string]any + Headers map[string]string +} + +type SMTPSender struct { + config config.MailConfig + renderer *Renderer +} + +func NewSMTPSender(cfg config.MailConfig, renderer *Renderer) *SMTPSender { + return &SMTPSender{ + config: cfg, + renderer: renderer, + } +} + +func (s *SMTPSender) Send(ctx context.Context, msg Message) error { + if s.config.Host == "" { + logger.Logger.Info("SMTP not configured, email not sent", "template", msg.Template) + return nil + } + + htmlBody, textBody, err := s.renderer.Render(msg.Template, msg.Locale, msg.Data) + if err != nil { + return fmt.Errorf("failed to render email template: %w", err) + } + + m := mail.NewMessage() + + from := s.config.From + if from == "" { + return fmt.Errorf("ACKIFY_MAIL_FROM not set") + } + m.SetHeader("From", m.FormatAddress(from, s.config.FromName)) + + if len(msg.To) == 0 { + return fmt.Errorf("no recipients specified") + } + m.SetHeader("To", msg.To...) + + if len(msg.Cc) > 0 { + m.SetHeader("Cc", msg.Cc...) + } + + if len(msg.Bcc) > 0 { + m.SetHeader("Bcc", msg.Bcc...) + } + + subject := msg.Subject + if s.config.SubjectPrefix != "" { + subject = s.config.SubjectPrefix + subject + } + m.SetHeader("Subject", subject) + + for key, value := range msg.Headers { + m.SetHeader(key, value) + } + + m.SetBody("text/plain", textBody) + m.AddAlternative("text/html", htmlBody) + + timeout, err := time.ParseDuration(s.config.Timeout) + if err != nil { + timeout = 10 * time.Second + } + + d := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password) + + if s.config.TLS { + d.SSL = true + } + + if s.config.StartTLS { + d.TLSConfig = &tls.Config{ServerName: s.config.Host} + } + + d.Timeout = timeout + + logger.Logger.Info("Sending email", "to", msg.To, "template", msg.Template, "locale", msg.Locale) + + if err := d.DialAndSend(m); err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + logger.Logger.Info("Email sent successfully", "to", msg.To) + return nil +} diff --git a/templates/emails/base.html.tmpl b/templates/emails/base.html.tmpl new file mode 100644 index 0000000..d51477d --- /dev/null +++ b/templates/emails/base.html.tmpl @@ -0,0 +1,61 @@ +{{define "base"}} + + + + + + {{.Organisation}} + + + +
+

{{.Organisation}}

+
+ +
+ {{template "content" .}} +
+ + + + +{{end}} diff --git a/templates/emails/base.txt.tmpl b/templates/emails/base.txt.tmpl new file mode 100644 index 0000000..7a0c39f --- /dev/null +++ b/templates/emails/base.txt.tmpl @@ -0,0 +1,13 @@ +{{define "base"}} +======================================== +{{.Organisation}} +======================================== + +{{template "content" .}} + +---------------------------------------- +This email was sent by {{.Organisation}} +{{.BaseURL}} + +Powered by Ackify - Proof of Read +{{end}} diff --git a/templates/emails/signature_reminder.en.html.tmpl b/templates/emails/signature_reminder.en.html.tmpl new file mode 100644 index 0000000..221018f --- /dev/null +++ b/templates/emails/signature_reminder.en.html.tmpl @@ -0,0 +1,29 @@ +{{define "content"}} +

Document Signature Reminder

+ +

Hello,

+ +

This is a reminder that the following document requires your signature:

+ +
+

Document ID: {{.Data.DocID}}

+
+ +

To review and sign this document, please follow these steps:

+ +
    +
  1. Review the document at: {{.Data.DocURL}}
  2. +
  3. Sign the document at: {{.Data.SignURL}}
  4. +
+ +
+ Sign Document Now +
+ +

Your cryptographic signature will provide verifiable proof that you have read and acknowledged this document.

+ +

If you have any questions, please contact your administrator.

+ +

Best regards,
+The {{.Organisation}} Team

+{{end}} diff --git a/templates/emails/signature_reminder.en.txt.tmpl b/templates/emails/signature_reminder.en.txt.tmpl new file mode 100644 index 0000000..2101164 --- /dev/null +++ b/templates/emails/signature_reminder.en.txt.tmpl @@ -0,0 +1,21 @@ +{{define "content"}} +Document Signature Reminder + +Hello, + +This is a reminder that the following document requires your signature: + +Document ID: {{.Data.DocID}} + +To review and sign this document, please follow these steps: + +1. Review the document at: {{.Data.DocURL}} +2. Sign the document at: {{.Data.SignURL}} + +Your cryptographic signature will provide verifiable proof that you have read and acknowledged this document. + +If you have any questions, please contact your administrator. + +Best regards, +The {{.Organisation}} Team +{{end}} diff --git a/templates/emails/signature_reminder.fr.html.tmpl b/templates/emails/signature_reminder.fr.html.tmpl new file mode 100644 index 0000000..3db098b --- /dev/null +++ b/templates/emails/signature_reminder.fr.html.tmpl @@ -0,0 +1,29 @@ +{{define "content"}} +

Rappel de signature de document

+ +

Bonjour,

+ +

Ceci est un rappel que le document suivant nécessite votre signature :

+ +
+

ID du document : {{.Data.DocID}}

+
+ +

Pour consulter et signer ce document, veuillez suivre ces étapes :

+ +
    +
  1. Consulter le document à : {{.Data.DocURL}}
  2. +
  3. Signer le document à : {{.Data.SignURL}}
  4. +
+ +
+ Signer le document maintenant +
+ +

Votre signature cryptographique fournira une preuve vérifiable que vous avez lu et pris connaissance de ce document.

+ +

Si vous avez des questions, veuillez contacter votre administrateur.

+ +

Cordialement,
+L'équipe {{.Organisation}}

+{{end}} diff --git a/templates/emails/signature_reminder.fr.txt.tmpl b/templates/emails/signature_reminder.fr.txt.tmpl new file mode 100644 index 0000000..e0dc497 --- /dev/null +++ b/templates/emails/signature_reminder.fr.txt.tmpl @@ -0,0 +1,21 @@ +{{define "content"}} +Rappel de signature de document + +Bonjour, + +Ceci est un rappel que le document suivant nécessite votre signature : + +ID du document : {{.Data.DocID}} + +Pour consulter et signer ce document, veuillez suivre ces étapes : + +1. Consulter le document à : {{.Data.DocURL}} +2. Signer le document à : {{.Data.SignURL}} + +Votre signature cryptographique fournira une preuve vérifiable que vous avez lu et pris connaissance de ce document. + +Si vous avez des questions, veuillez contacter votre administrateur. + +Cordialement, +L'équipe {{.Organisation}} +{{end}}