mirror of
https://github.com/btouchard/ackify.git
synced 2026-01-05 20:39:39 -06:00
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
This commit is contained in:
5
go.mod
5
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
|
||||
)
|
||||
|
||||
6
go.sum
6
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=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
33
internal/infrastructure/email/helpers.go
Normal file
33
internal/infrastructure/email/helpers.go
Normal file
@@ -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)
|
||||
}
|
||||
121
internal/infrastructure/email/renderer.go
Normal file
121
internal/infrastructure/email/renderer.go
Normal file
@@ -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 ""
|
||||
}
|
||||
113
internal/infrastructure/email/sender.go
Normal file
113
internal/infrastructure/email/sender.go
Normal file
@@ -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
|
||||
}
|
||||
61
templates/emails/base.html.tmpl
Normal file
61
templates/emails/base.html.tmpl
Normal file
@@ -0,0 +1,61 @@
|
||||
{{define "base"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Organisation}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #4F46E5;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #4F46E5;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.footer {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 20px;
|
||||
margin-top: 30px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
a {
|
||||
color: #4F46E5;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>{{.Organisation}}</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{{template "content" .}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This email was sent by <a href="{{.BaseURL}}">{{.Organisation}}</a></p>
|
||||
<p>Powered by Ackify - Proof of Read</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
13
templates/emails/base.txt.tmpl
Normal file
13
templates/emails/base.txt.tmpl
Normal file
@@ -0,0 +1,13 @@
|
||||
{{define "base"}}
|
||||
========================================
|
||||
{{.Organisation}}
|
||||
========================================
|
||||
|
||||
{{template "content" .}}
|
||||
|
||||
----------------------------------------
|
||||
This email was sent by {{.Organisation}}
|
||||
{{.BaseURL}}
|
||||
|
||||
Powered by Ackify - Proof of Read
|
||||
{{end}}
|
||||
29
templates/emails/signature_reminder.en.html.tmpl
Normal file
29
templates/emails/signature_reminder.en.html.tmpl
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "content"}}
|
||||
<h2>Document Signature Reminder</h2>
|
||||
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>This is a reminder that the following document requires your signature:</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Document ID:</strong> {{.Data.DocID}}</p>
|
||||
</div>
|
||||
|
||||
<p>To review and sign this document, please follow these steps:</p>
|
||||
|
||||
<ol>
|
||||
<li>Review the document at: <a href="{{.Data.DocURL}}">{{.Data.DocURL}}</a></li>
|
||||
<li>Sign the document at: <a href="{{.Data.SignURL}}">{{.Data.SignURL}}</a></li>
|
||||
</ol>
|
||||
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="{{.Data.SignURL}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Sign Document Now</a>
|
||||
</div>
|
||||
|
||||
<p>Your cryptographic signature will provide verifiable proof that you have read and acknowledged this document.</p>
|
||||
|
||||
<p>If you have any questions, please contact your administrator.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
The {{.Organisation}} Team</p>
|
||||
{{end}}
|
||||
21
templates/emails/signature_reminder.en.txt.tmpl
Normal file
21
templates/emails/signature_reminder.en.txt.tmpl
Normal file
@@ -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}}
|
||||
29
templates/emails/signature_reminder.fr.html.tmpl
Normal file
29
templates/emails/signature_reminder.fr.html.tmpl
Normal file
@@ -0,0 +1,29 @@
|
||||
{{define "content"}}
|
||||
<h2>Rappel de signature de document</h2>
|
||||
|
||||
<p>Bonjour,</p>
|
||||
|
||||
<p>Ceci est un rappel que le document suivant nécessite votre signature :</p>
|
||||
|
||||
<div style="background-color: #f3f4f6; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>ID du document :</strong> {{.Data.DocID}}</p>
|
||||
</div>
|
||||
|
||||
<p>Pour consulter et signer ce document, veuillez suivre ces étapes :</p>
|
||||
|
||||
<ol>
|
||||
<li>Consulter le document à : <a href="{{.Data.DocURL}}">{{.Data.DocURL}}</a></li>
|
||||
<li>Signer le document à : <a href="{{.Data.SignURL}}">{{.Data.SignURL}}</a></li>
|
||||
</ol>
|
||||
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="{{.Data.SignURL}}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Signer le document maintenant</a>
|
||||
</div>
|
||||
|
||||
<p>Votre signature cryptographique fournira une preuve vérifiable que vous avez lu et pris connaissance de ce document.</p>
|
||||
|
||||
<p>Si vous avez des questions, veuillez contacter votre administrateur.</p>
|
||||
|
||||
<p>Cordialement,<br>
|
||||
L'équipe {{.Organisation}}</p>
|
||||
{{end}}
|
||||
21
templates/emails/signature_reminder.fr.txt.tmpl
Normal file
21
templates/emails/signature_reminder.fr.txt.tmpl
Normal file
@@ -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}}
|
||||
Reference in New Issue
Block a user