Improvements, test & documentatons (2025-11 #2)

[root]
- UPDATE documentation: enhanced README and TESTING guidelines
- UPDATE dependencies: bump dependencies

[config/components]
- UPDATE mail component: apply update following changes in related package
- UPDATE smtp component: apply update following changes in related package

[mail] - MAJOR REFACTORING
- REFACTOR package structure: reorganized into 4 specialized subpackages (queuer, render, sender, smtp)
- ADD mail/queuer: mail queue management with counter, monitoring, and comprehensive tests
- ADD mail/render: email template rendering with themes and direction handling (moved from mailer package)
- ADD mail/sender: email composition and sending with attachments, priorities, and encoding
- ADD mail/smtp: SMTP protocol handling with TLS modes and DSN support
- ADD documentation: comprehensive README and TESTING for all subpackages
- ADD tests: complete test suites with benchmarks, concurrency, and edge cases for all subpackages

[mailer] - DEPRECATED
- DELETE package: entire package merged into mail/render

[mailPooler] - DEPRECATED
- DELETE package: entire package merged into mail/queuer

[smtp] - DEPRECATED
- DELETE root package: entire package moved to mail/smtp
- REFACTOR tlsmode: enhanced with encoding, formatting, and viper support (moved to mail/smtp/tlsmode)

[size]
- ADD documentation: comprehensive README
- UPDATE interface: improved Size type methods
- UPDATE encoding: enhanced marshaling support
- UPDATE formatting: better unit handling and display
- UPDATE parsing: improved error handling and validation

[socket/server/unix]
- ADD platform support: macOS-specific permission handling (perm_darwin.go)
- ADD platform support: Linux-specific permission handling (perm_linux.go)
- UPDATE listener: improved Unix socket and datagram listeners
- UPDATE error handling: enhanced error messages for Unix sockets

[socket/server/unixgram]
- ADD platform support: macOS-specific permission handling (perm_darwin.go)
- ADD platform support: Linux-specific permission handling (perm_linux.go)
- UPDATE listener: improved Unix datagram listener
- UPDATE error handling: enhanced error messages

[socket/server/tcp]
- UPDATE listener: improved TCP listener implementation
This commit is contained in:
nabbar
2025-11-16 21:41:50 +01:00
parent 0656703eae
commit 25c3c8c45b
154 changed files with 35011 additions and 3480 deletions

1034
mail/render/README.md Normal file

File diff suppressed because it is too large Load Diff

1012
mail/render/TESTING.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,485 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"github.com/go-hermes/hermes/v2"
"github.com/nabbar/golib/mail/render"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gmeasure"
)
var _ = Describe("Benchmarks", func() {
Describe("Creation Benchmarks", func() {
Context("mailer creation performance", func() {
It("should benchmark New() creation", func() {
experiment := gmeasure.NewExperiment("Mailer Creation")
AddReportEntry(experiment.Name, experiment)
experiment.Sample(func(idx int) {
experiment.MeasureDuration("new", func() {
mailer := render.New()
Expect(mailer).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 1000, Duration: 0})
stats := experiment.GetStats("new")
AddReportEntry("Mean creation time", stats.DurationFor(gmeasure.StatMean))
})
It("should benchmark Config.NewMailer() creation", func() {
experiment := gmeasure.NewExperiment("Config Creation")
AddReportEntry(experiment.Name, experiment)
config := render.Config{
Theme: "default",
Direction: "ltr",
Name: "Test",
Link: "https://example.com",
Logo: "https://example.com/logo.png",
Copyright: "© 2024",
TroubleText: "Help",
Body: hermes.Body{Name: "User"},
}
experiment.Sample(func(idx int) {
experiment.MeasureDuration("config-new", func() {
mailer := config.NewMailer()
Expect(mailer).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 1000})
stats := experiment.GetStats("config-new")
AddReportEntry("Mean config creation time", stats.DurationFor(gmeasure.StatMean))
})
})
})
Describe("Clone Benchmarks", func() {
Context("cloning performance", func() {
It("should benchmark simple clone", func() {
experiment := gmeasure.NewExperiment("Simple Clone")
AddReportEntry(experiment.Name, experiment)
mailer := render.New()
mailer.SetName("Test")
body := &hermes.Body{Name: "User"}
mailer.SetBody(body)
experiment.Sample(func(idx int) {
experiment.MeasureDuration("clone", func() {
clone := mailer.Clone()
Expect(clone).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 1000})
stats := experiment.GetStats("clone")
AddReportEntry("Mean simple clone time", stats.DurationFor(gmeasure.StatMean))
})
It("should benchmark complex clone", func() {
experiment := gmeasure.NewExperiment("Complex Clone")
AddReportEntry(experiment.Name, experiment)
mailer := render.New()
body := &hermes.Body{
Name: "User",
Intros: []string{"Intro 1", "Intro 2", "Intro 3"},
Outros: []string{"Outro 1", "Outro 2"},
Dictionary: []hermes.Entry{
{Key: "K1", Value: "V1"},
{Key: "K2", Value: "V2"},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{{Key: "C1", Value: "V1"}, {Key: "C2", Value: "V2"}},
{{Key: "C1", Value: "V3"}, {Key: "C2", Value: "V4"}},
},
}},
Actions: []hermes.Action{
{
Instructions: "Test",
Button: hermes.Button{Text: "Click", Link: "https://example.com"},
},
},
}
mailer.SetBody(body)
experiment.Sample(func(idx int) {
experiment.MeasureDuration("clone-complex", func() {
clone := mailer.Clone()
Expect(clone).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 500})
stats := experiment.GetStats("clone-complex")
AddReportEntry("Mean complex clone time", stats.DurationFor(gmeasure.StatMean))
})
})
})
Describe("Generation Benchmarks", func() {
var mailer render.Mailer
BeforeEach(func() {
mailer = render.New()
mailer.SetName("Test Company")
mailer.SetLink("https://example.com")
mailer.SetLogo("https://example.com/logo.png")
mailer.SetCopyright("© 2024")
mailer.SetTroubleText("Contact us")
})
Context("HTML generation performance", func() {
It("should benchmark simple HTML generation", func() {
experiment := gmeasure.NewExperiment("Simple HTML")
AddReportEntry(experiment.Name, experiment)
body := &hermes.Body{
Name: "User",
Intros: []string{"Welcome!"},
}
mailer.SetBody(body)
experiment.Sample(func(idx int) {
experiment.MeasureDuration("html-simple", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 100})
stats := experiment.GetStats("html-simple")
AddReportEntry("Mean simple HTML time", stats.DurationFor(gmeasure.StatMean))
AddReportEntry("Max simple HTML time", stats.DurationFor(gmeasure.StatMax))
})
It("should benchmark complex HTML generation", func() {
experiment := gmeasure.NewExperiment("Complex HTML")
AddReportEntry(experiment.Name, experiment)
body := &hermes.Body{
Name: "User",
Intros: []string{"Welcome!", "Here's your report"},
Dictionary: []hermes.Entry{
{Key: "ID", Value: "123"},
{Key: "Date", Value: "2024-01-01"},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{{Key: "Item", Value: "A"}, {Key: "Price", Value: "$10"}},
{{Key: "Item", Value: "B"}, {Key: "Price", Value: "$20"}},
{{Key: "Item", Value: "C"}, {Key: "Price", Value: "$30"}},
},
}},
Actions: []hermes.Action{
{
Instructions: "View details",
Button: hermes.Button{Text: "View", Link: "https://example.com"},
},
},
Outros: []string{"Thank you", "Best regards"},
}
mailer.SetBody(body)
experiment.Sample(func(idx int) {
experiment.MeasureDuration("html-complex", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 100})
stats := experiment.GetStats("html-complex")
AddReportEntry("Mean complex HTML time", stats.DurationFor(gmeasure.StatMean))
AddReportEntry("Max complex HTML time", stats.DurationFor(gmeasure.StatMax))
})
})
Context("plain text generation performance", func() {
It("should benchmark plain text generation", func() {
experiment := gmeasure.NewExperiment("Plain Text")
AddReportEntry(experiment.Name, experiment)
body := &hermes.Body{
Name: "User",
Intros: []string{"Welcome!"},
}
mailer.SetBody(body)
experiment.Sample(func(idx int) {
experiment.MeasureDuration("text-simple", func() {
buf, err := mailer.GeneratePlainText()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 100})
stats := experiment.GetStats("text-simple")
AddReportEntry("Mean plain text time", stats.DurationFor(gmeasure.StatMean))
})
})
Context("theme comparison", func() {
It("should compare generation time between themes", func() {
experiment := gmeasure.NewExperiment("Theme Comparison")
AddReportEntry(experiment.Name, experiment)
body := &hermes.Body{
Name: "User",
Intros: []string{"Test email"},
Actions: []hermes.Action{
{Button: hermes.Button{Text: "Click", Link: "https://example.com"}},
},
}
experiment.Sample(func(idx int) {
mailer.SetTheme(render.ThemeDefault)
mailer.SetBody(body)
experiment.MeasureDuration("theme-default", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 100})
experiment.Sample(func(idx int) {
mailer.SetTheme(render.ThemeFlat)
mailer.SetBody(body)
experiment.MeasureDuration("theme-flat", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 100})
defaultStats := experiment.GetStats("theme-default")
flatStats := experiment.GetStats("theme-flat")
AddReportEntry("Mean default theme time", defaultStats.DurationFor(gmeasure.StatMean))
AddReportEntry("Mean flat theme time", flatStats.DurationFor(gmeasure.StatMean))
})
})
})
Describe("ParseData Benchmarks", func() {
Context("template parsing performance", func() {
It("should benchmark simple ParseData", func() {
experiment := gmeasure.NewExperiment("Simple ParseData")
AddReportEntry(experiment.Name, experiment)
data := map[string]string{
"{{user}}": "John",
"{{company}}": "Test Inc",
}
experiment.Sample(func(idx int) {
mailer := render.New()
body := &hermes.Body{
Name: "{{user}}",
Intros: []string{"Hello {{user}}"},
}
mailer.SetBody(body)
mailer.SetName("{{company}}")
experiment.MeasureDuration("parse-simple", func() {
mailer.ParseData(data)
})
}, gmeasure.SamplingConfig{N: 500})
stats := experiment.GetStats("parse-simple")
AddReportEntry("Mean simple parse time", stats.DurationFor(gmeasure.StatMean))
})
It("should benchmark complex ParseData", func() {
experiment := gmeasure.NewExperiment("Complex ParseData")
AddReportEntry(experiment.Name, experiment)
data := map[string]string{
"{{user}}": "John",
"{{company}}": "Test Inc",
"{{code}}": "123456",
"{{link}}": "https://example.com",
"{{email}}": "user@example.com",
}
experiment.Sample(func(idx int) {
mailer := render.New()
body := &hermes.Body{
Name: "{{user}}",
Intros: []string{"Hello {{user}}", "Your code: {{code}}"},
Dictionary: []hermes.Entry{
{Key: "Company", Value: "{{company}}"},
{Key: "Email", Value: "{{email}}"},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{{Key: "Link", Value: "{{link}}"}},
},
}},
Actions: []hermes.Action{
{
Instructions: "Visit {{link}}",
Button: hermes.Button{Text: "Visit", Link: "{{link}}"},
},
},
}
mailer.SetBody(body)
mailer.SetName("{{company}}")
experiment.MeasureDuration("parse-complex", func() {
mailer.ParseData(data)
})
}, gmeasure.SamplingConfig{N: 500})
stats := experiment.GetStats("parse-complex")
AddReportEntry("Mean complex parse time", stats.DurationFor(gmeasure.StatMean))
})
})
})
Describe("Complete Workflow Benchmark", func() {
Context("end-to-end performance", func() {
It("should benchmark complete email generation workflow", func() {
experiment := gmeasure.NewExperiment("Complete Workflow")
AddReportEntry(experiment.Name, experiment)
config := render.Config{
Theme: "default",
Direction: "ltr",
Name: "{{company}}",
Link: "https://example.com",
Logo: "https://example.com/logo.png",
Copyright: "© 2024 {{company}}",
TroubleText: "Contact us",
Body: hermes.Body{
Name: "{{user}}",
Intros: []string{"Welcome {{user}}!"},
Actions: []hermes.Action{
{
Instructions: "Verify your account:",
Button: hermes.Button{Text: "Verify", Link: "{{link}}"},
},
},
},
}
data := map[string]string{
"{{company}}": "Test Inc",
"{{user}}": "John Doe",
"{{link}}": "https://example.com/verify",
}
experiment.Sample(func(idx int) {
experiment.MeasureDuration("workflow", func() {
mailer := config.NewMailer()
mailer.ParseData(data)
htmlBuf, htmlErr := mailer.GenerateHTML()
textBuf, textErr := mailer.GeneratePlainText()
Expect(htmlErr).To(BeNil())
Expect(textErr).To(BeNil())
Expect(htmlBuf).ToNot(BeNil())
Expect(textBuf).ToNot(BeNil())
})
}, gmeasure.SamplingConfig{N: 100})
stats := experiment.GetStats("workflow")
AddReportEntry("Mean workflow time", stats.DurationFor(gmeasure.StatMean))
AddReportEntry("Max workflow time", stats.DurationFor(gmeasure.StatMax))
})
})
})
Describe("Parsing Benchmarks", func() {
Context("theme and direction parsing", func() {
It("should benchmark ParseTheme", func() {
experiment := gmeasure.NewExperiment("ParseTheme")
AddReportEntry(experiment.Name, experiment)
themes := []string{"default", "flat", "Default", "FLAT", "unknown"}
experiment.Sample(func(idx int) {
theme := themes[idx%len(themes)]
experiment.MeasureDuration("parse-theme", func() {
_ = render.ParseTheme(theme)
})
}, gmeasure.SamplingConfig{N: 1000})
stats := experiment.GetStats("parse-theme")
AddReportEntry("Mean theme parse time", stats.DurationFor(gmeasure.StatMean))
})
It("should benchmark ParseTextDirection", func() {
experiment := gmeasure.NewExperiment("ParseTextDirection")
AddReportEntry(experiment.Name, experiment)
directions := []string{"ltr", "rtl", "left-to-right", "right-to-left", "LTR"}
experiment.Sample(func(idx int) {
dir := directions[idx%len(directions)]
experiment.MeasureDuration("parse-direction", func() {
_ = render.ParseTextDirection(dir)
})
}, gmeasure.SamplingConfig{N: 1000})
stats := experiment.GetStats("parse-direction")
AddReportEntry("Mean direction parse time", stats.DurationFor(gmeasure.StatMean))
})
})
})
Describe("Validation Benchmark", func() {
Context("config validation performance", func() {
It("should benchmark Config.Validate()", func() {
experiment := gmeasure.NewExperiment("Config Validation")
AddReportEntry(experiment.Name, experiment)
validConfig := render.Config{
Theme: "default",
Direction: "ltr",
Name: "Test",
Link: "https://example.com",
Logo: "https://example.com/logo.png",
Copyright: "© 2024",
TroubleText: "Help",
Body: hermes.Body{Name: "User"},
}
experiment.Sample(func(idx int) {
experiment.MeasureDuration("validate", func() {
err := validConfig.Validate()
Expect(err).To(BeNil())
})
}, gmeasure.SamplingConfig{N: 500})
stats := experiment.GetStats("validate")
AddReportEntry("Mean validation time", stats.DurationFor(gmeasure.StatMean))
})
})
})
})

View File

@@ -0,0 +1,441 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"sync"
"github.com/go-hermes/hermes/v2"
"github.com/nabbar/golib/mail/render"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Concurrency", func() {
Describe("Concurrent Clone Operations", func() {
Context("when cloning from multiple goroutines", func() {
It("should safely clone mailer concurrently", func() {
mailer := render.New()
mailer.SetName("Original")
body := &hermes.Body{
Name: "User",
Intros: []string{"Intro 1", "Intro 2"},
}
mailer.SetBody(body)
var wg sync.WaitGroup
const numGoroutines = 100
results := make([]render.Mailer, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer GinkgoRecover()
defer wg.Done()
clone := mailer.Clone()
results[index] = clone
// Verify clone has correct values
Expect(clone.GetName()).To(Equal("Original"))
Expect(clone.GetBody().Name).To(Equal("User"))
Expect(clone.GetBody().Intros).To(HaveLen(2))
}(i)
}
wg.Wait()
// Verify all clones were created
for _, clone := range results {
Expect(clone).ToNot(BeNil())
}
})
It("should create independent clones under concurrent load", func() {
mailer := render.New()
body := &hermes.Body{
Intros: []string{"Original"},
}
mailer.SetBody(body)
var wg sync.WaitGroup
const numGoroutines = 50
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer GinkgoRecover()
defer wg.Done()
clone := mailer.Clone()
// Modify the clone
cloneBody := clone.GetBody()
cloneBody.Intros = append(cloneBody.Intros, "Modified")
// Clone should have 2 intros
Expect(len(cloneBody.Intros)).To(BeNumerically(">=", 2))
}(i)
}
wg.Wait()
// Original should still have 1 intro
originalBody := mailer.GetBody()
Expect(originalBody.Intros).To(HaveLen(1))
Expect(originalBody.Intros[0]).To(Equal("Original"))
})
})
})
Describe("Concurrent Read Operations", func() {
Context("when reading from multiple goroutines", func() {
It("should safely read mailer properties concurrently", func() {
mailer := render.New()
mailer.SetName("Test Company")
mailer.SetLink("https://example.com")
mailer.SetLogo("https://example.com/logo.png")
mailer.SetCopyright("© 2024")
mailer.SetTroubleText("Contact us")
mailer.SetTheme(render.ThemeFlat)
mailer.SetTextDirection(render.RightToLeft)
var wg sync.WaitGroup
const numGoroutines = 100
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
// Read all properties
name := mailer.GetName()
link := mailer.GetLink()
logo := mailer.GetLogo()
copyright := mailer.GetCopyright()
troubleText := mailer.GetTroubleText()
theme := mailer.GetTheme()
direction := mailer.GetTextDirection()
body := mailer.GetBody()
// Verify values
Expect(name).To(Equal("Test Company"))
Expect(link).To(Equal("https://example.com"))
Expect(logo).To(Equal("https://example.com/logo.png"))
Expect(copyright).To(Equal("© 2024"))
Expect(troubleText).To(Equal("Contact us"))
Expect(theme).To(Equal(render.ThemeFlat))
Expect(direction).To(Equal(render.RightToLeft))
Expect(body).ToNot(BeNil())
}()
}
wg.Wait()
})
})
})
Describe("Concurrent Generation", func() {
Context("when generating HTML from multiple goroutines", func() {
It("should safely generate HTML concurrently using independent clones", func() {
mailer := render.New()
mailer.SetName("Test Company")
mailer.SetLink("https://example.com")
mailer.SetLogo("https://example.com/logo.png")
body := &hermes.Body{
Name: "User",
Intros: []string{"Welcome!"},
}
mailer.SetBody(body)
var wg sync.WaitGroup
const numGoroutines = 50
results := make(chan bool, numGoroutines)
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
// Clone to avoid concurrent modifications
clone := mailer.Clone()
buf, err := clone.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
Expect(buf.Len()).To(BeNumerically(">", 0))
results <- true
}()
}
wg.Wait()
close(results)
count := 0
for range results {
count++
}
Expect(count).To(Equal(numGoroutines))
})
It("should safely generate plain text concurrently using independent clones", func() {
mailer := render.New()
mailer.SetName("Test Company")
body := &hermes.Body{
Name: "User",
Intros: []string{"Test"},
}
mailer.SetBody(body)
var wg sync.WaitGroup
const numGoroutines = 50
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func() {
defer GinkgoRecover()
defer wg.Done()
clone := mailer.Clone()
buf, err := clone.GeneratePlainText()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
}()
}
wg.Wait()
})
})
})
Describe("Concurrent Configuration Updates", func() {
Context("when multiple goroutines update different mailers", func() {
It("should handle concurrent updates on independent mailers", func() {
var wg sync.WaitGroup
const numGoroutines = 50
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer GinkgoRecover()
defer wg.Done()
mailer := render.New()
mailer.SetName("Mailer " + string(rune(index)))
mailer.SetTheme(render.ThemeFlat)
body := &hermes.Body{
Name: "User " + string(rune(index)),
}
mailer.SetBody(body)
// Generate to ensure no race conditions
_, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
}(i)
}
wg.Wait()
})
})
})
Describe("Concurrent ParseData", func() {
Context("when parsing data in multiple goroutines", func() {
It("should safely parse data on independent clones", func() {
baseMailer := render.New()
body := &hermes.Body{
Name: "{{user}}",
Intros: []string{"Hello {{user}}"},
}
baseMailer.SetBody(body)
var wg sync.WaitGroup
const numGoroutines = 50
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(index int) {
defer GinkgoRecover()
defer wg.Done()
// Clone for independent operation
mailer := baseMailer.Clone()
data := map[string]string{
"{{user}}": "User " + string(rune(index)),
}
mailer.ParseData(data)
body := mailer.GetBody()
Expect(body.Name).To(ContainSubstring("User"))
}(i)
}
wg.Wait()
// Original should still have template variables
originalBody := baseMailer.GetBody()
Expect(originalBody.Name).To(Equal("{{user}}"))
})
})
})
Describe("Mixed Concurrent Operations", func() {
Context("when performing various operations concurrently", func() {
It("should handle mixed operations safely", func() {
mailer := render.New()
mailer.SetName("Base")
body := &hermes.Body{
Name: "{{user}}",
Intros: []string{"Welcome"},
}
mailer.SetBody(body)
var wg sync.WaitGroup
const numGoroutines = 100
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
// Distribute different operations
switch i % 4 {
case 0:
// Clone operation
go func() {
defer GinkgoRecover()
defer wg.Done()
clone := mailer.Clone()
Expect(clone).ToNot(BeNil())
}()
case 1:
// Read operation
go func() {
defer GinkgoRecover()
defer wg.Done()
name := mailer.GetName()
Expect(name).To(Equal("Base"))
}()
case 2:
// Generate HTML
go func() {
defer GinkgoRecover()
defer wg.Done()
clone := mailer.Clone()
_, err := clone.GenerateHTML()
Expect(err).To(BeNil())
}()
case 3:
// Parse data on clone
go func() {
defer GinkgoRecover()
defer wg.Done()
clone := mailer.Clone()
clone.ParseData(map[string]string{
"{{user}}": "Test",
})
Expect(clone.GetBody().Name).To(Equal("Test"))
}()
}
}
wg.Wait()
// Original should remain unchanged
Expect(mailer.GetName()).To(Equal("Base"))
Expect(mailer.GetBody().Name).To(Equal("{{user}}"))
})
})
})
Describe("High Load Stress Test", func() {
Context("under high concurrent load", func() {
It("should remain stable with many concurrent operations", func() {
config := render.Config{
Theme: "default",
Direction: "ltr",
Name: "Stress Test",
Link: "https://example.com",
Logo: "https://example.com/logo.png",
Copyright: "© 2024",
TroubleText: "Help",
Body: hermes.Body{
Name: "{{user}}",
Intros: []string{"Stress test email"},
},
}
baseMailer := config.NewMailer()
var wg sync.WaitGroup
const highLoad = 200
successCount := make(chan bool, highLoad)
for i := 0; i < highLoad; i++ {
wg.Add(1)
go func(index int) {
defer GinkgoRecover()
defer wg.Done()
clone := baseMailer.Clone()
clone.ParseData(map[string]string{
"{{user}}": "User" + string(rune(index)),
})
htmlBuf, htmlErr := clone.GenerateHTML()
textBuf, textErr := clone.GeneratePlainText()
if htmlErr == nil && textErr == nil &&
htmlBuf != nil && textBuf != nil {
successCount <- true
}
}(i)
}
wg.Wait()
close(successCount)
count := 0
for range successCount {
count++
}
// Should have high success rate
Expect(count).To(BeNumerically(">=", highLoad*0.95))
})
})
})
})

172
mail/render/config.go Normal file
View File

@@ -0,0 +1,172 @@
/*
* MIT License
*
* Copyright (c) 2021 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*
*/
package render
import (
"fmt"
libhms "github.com/go-hermes/hermes/v2"
libval "github.com/go-playground/validator/v10"
liberr "github.com/nabbar/golib/errors"
)
// Config represents the configuration structure for creating a Mailer instance.
// It supports multiple serialization formats (JSON, YAML, TOML) and includes
// validation tags for ensuring configuration correctness.
//
// All fields except DisableCSSInline are required and must pass validation.
// The Link and Logo fields must be valid URLs.
//
// This struct is designed to be used with configuration files or environment
// variables via libraries like Viper or similar configuration management tools.
//
// Example JSON configuration:
//
// {
// "theme": "flat",
// "direction": "ltr",
// "name": "My Company",
// "link": "https://example.com",
// "logo": "https://example.com/logo.png",
// "copyright": "© 2024 My Company",
// "troubleText": "Need help? Contact support@example.com",
// "disableCSSInline": false,
// "body": {
// "name": "John Doe",
// "intros": ["Welcome to our service!"]
// }
// }
type Config struct {
// Theme specifies the email template theme ("default" or "flat")
Theme string `json:"theme,omitempty" yaml:"theme,omitempty" toml:"theme,omitempty" mapstructure:"theme,omitempty" validate:"required"`
// Direction specifies the text direction ("ltr" or "rtl")
Direction string `json:"direction,omitempty" yaml:"direction,omitempty" toml:"direction,omitempty" mapstructure:"direction,omitempty" validate:"required"`
// Name is the product/company name displayed in the email
Name string `json:"name,omitempty" yaml:"name,omitempty" toml:"name,omitempty" mapstructure:"name,omitempty" validate:"required"`
// Link is the primary product/company URL (must be a valid URL)
Link string `json:"link,omitempty" yaml:"link,omitempty" toml:"link,omitempty" mapstructure:"link,omitempty" validate:"required,url"`
// Logo is the URL of the logo image (must be a valid URL)
Logo string `json:"logo,omitempty" yaml:"logo,omitempty" toml:"logo,omitempty" mapstructure:"logo,omitempty" validate:"required,url"`
// Copyright is the copyright text displayed in the footer
Copyright string `json:"copyright,omitempty" yaml:"copyright,omitempty" toml:"copyright,omitempty" mapstructure:"copyright,omitempty" validate:"required"`
// TroubleText is the help/support text displayed for troubleshooting
TroubleText string `json:"troubleText,omitempty" yaml:"troubleText,omitempty" toml:"troubleText,omitempty" mapstructure:"troubleText,omitempty" validate:"required"`
// DisableCSSInline controls whether to disable CSS inlining in HTML output
DisableCSSInline bool `json:"disableCSSInline,omitempty" yaml:"disableCSSInline,omitempty" toml:"disableCSSInline,omitempty" mapstructure:"disableCSSInline,omitempty"`
// Body contains the email body content structure
// See github.com/go-hermes/hermes/v2 Body for field details
Body libhms.Body `json:"body" yaml:"body" toml:"body" mapstructure:"body" validate:"required"`
}
// Validate validates the Config struct using validator tags.
// It checks all required fields and validates URL formats for Link and Logo.
//
// Returns:
// - nil if validation succeeds
// - liberr.Error containing all validation errors if validation fails
//
// Validation rules:
// - All fields except DisableCSSInline are required
// - Link must be a valid URL
// - Logo must be a valid URL
//
// Example:
//
// config := render.Config{
// Theme: "flat",
// Direction: "ltr",
// Name: "Company",
// Link: "https://example.com",
// Logo: "https://example.com/logo.png",
// Copyright: "© 2024",
// TroubleText: "Help",
// Body: hermes.Body{},
// }
// if err := config.Validate(); err != nil {
// // Handle validation errors
// }
func (c Config) Validate() liberr.Error {
err := ErrorMailerConfigInvalid.Error(nil)
if er := libval.New().Struct(c); er != nil {
if e, ok := er.(*libval.InvalidValidationError); ok {
err.Add(e)
}
for _, e := range er.(libval.ValidationErrors) {
//nolint goerr113
err.Add(fmt.Errorf("config field '%s' is not validated by constraint '%s'", e.Namespace(), e.ActualTag()))
}
}
if err.HasParent() {
return err
}
return nil
}
// NewMailer creates a new Mailer instance from the configuration.
// It parses the theme and direction strings and initializes all fields.
//
// Note: This method does not validate the configuration. Call Validate()
// before NewMailer() to ensure the configuration is valid.
//
// Returns:
// - A configured Mailer instance ready to use
//
// Example:
//
// config := render.Config{
// Theme: "flat",
// Direction: "ltr",
// Name: "My Company",
// Link: "https://example.com",
// Logo: "https://example.com/logo.png",
// Copyright: "© 2024",
// TroubleText: "Need help?",
// Body: hermes.Body{Name: "User"},
// }
// if err := config.Validate(); err == nil {
// mailer := config.NewMailer()
// htmlBuf, _ := mailer.GenerateHTML()
// }
func (c Config) NewMailer() Mailer {
return &email{
t: ParseTheme(c.Theme),
d: ParseTextDirection(c.Direction),
p: libhms.Product{
Name: c.Name,
Link: c.Link,
Logo: c.Logo,
Copyright: c.Copyright,
TroubleText: c.TroubleText,
},
b: &c.Body,
c: c.DisableCSSInline,
}
}

282
mail/render/config_test.go Normal file
View File

@@ -0,0 +1,282 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"github.com/go-hermes/hermes/v2"
"github.com/nabbar/golib/mail/render"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Config", func() {
var validConfig render.Config
BeforeEach(func() {
validConfig = render.Config{
Theme: "default",
Direction: "ltr",
Name: "Test Company",
Link: "https://example.com",
Logo: "https://example.com/logo.png",
Copyright: "© 2024 Test Company",
TroubleText: "Having trouble?",
DisableCSSInline: false,
Body: hermes.Body{
Name: "John Doe",
Intros: []string{"Welcome to our service!"},
},
}
})
Describe("Validation", func() {
Context("with valid configuration", func() {
It("should validate successfully", func() {
err := validConfig.Validate()
Expect(err).To(BeNil())
})
})
Context("with missing required fields", func() {
It("should fail validation when theme is missing", func() {
validConfig.Theme = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation when direction is missing", func() {
validConfig.Direction = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation when name is missing", func() {
validConfig.Name = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation when link is missing", func() {
validConfig.Link = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation when logo is missing", func() {
validConfig.Logo = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation when copyright is missing", func() {
validConfig.Copyright = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation when trouble text is missing", func() {
validConfig.TroubleText = ""
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
})
Context("with invalid URL format", func() {
It("should fail validation for invalid link URL", func() {
validConfig.Link = "not-a-valid-url"
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should fail validation for invalid logo URL", func() {
validConfig.Logo = "not-a-valid-url"
err := validConfig.Validate()
Expect(err).ToNot(BeNil())
Expect(err.HasParent()).To(BeTrue())
})
It("should accept valid HTTP URLs", func() {
validConfig.Link = "http://example.com"
validConfig.Logo = "http://example.com/logo.png"
err := validConfig.Validate()
Expect(err).To(BeNil())
})
It("should accept valid HTTPS URLs", func() {
validConfig.Link = "https://example.com"
validConfig.Logo = "https://example.com/logo.png"
err := validConfig.Validate()
Expect(err).To(BeNil())
})
})
})
Describe("NewMailer", func() {
Context("when creating mailer from config", func() {
It("should create mailer with correct values", func() {
mailer := validConfig.NewMailer()
Expect(mailer).ToNot(BeNil())
Expect(mailer.GetName()).To(Equal(validConfig.Name))
Expect(mailer.GetLink()).To(Equal(validConfig.Link))
Expect(mailer.GetLogo()).To(Equal(validConfig.Logo))
Expect(mailer.GetCopyright()).To(Equal(validConfig.Copyright))
Expect(mailer.GetTroubleText()).To(Equal(validConfig.TroubleText))
Expect(mailer.GetTheme()).To(Equal(render.ThemeDefault))
Expect(mailer.GetTextDirection()).To(Equal(render.LeftToRight))
})
It("should create mailer with flat theme", func() {
validConfig.Theme = "flat"
mailer := validConfig.NewMailer()
Expect(mailer.GetTheme()).To(Equal(render.ThemeFlat))
})
It("should create mailer with right to left direction", func() {
validConfig.Direction = "rtl"
mailer := validConfig.NewMailer()
Expect(mailer.GetTextDirection()).To(Equal(render.RightToLeft))
})
It("should preserve body content", func() {
mailer := validConfig.NewMailer()
body := mailer.GetBody()
Expect(body).ToNot(BeNil())
Expect(body.Name).To(Equal("John Doe"))
Expect(body.Intros).To(HaveLen(1))
Expect(body.Intros[0]).To(Equal("Welcome to our service!"))
})
It("should handle CSS inline disable flag", func() {
validConfig.DisableCSSInline = true
mailer := validConfig.NewMailer()
// Should not panic
Expect(mailer).ToNot(BeNil())
})
})
Context("with different configurations", func() {
It("should handle minimal valid configuration", func() {
minConfig := render.Config{
Theme: "default",
Direction: "ltr",
Name: "Company",
Link: "https://company.com",
Logo: "https://company.com/logo.png",
Copyright: "©",
TroubleText: "Help",
Body: hermes.Body{},
}
err := minConfig.Validate()
Expect(err).To(BeNil())
mailer := minConfig.NewMailer()
Expect(mailer).ToNot(BeNil())
})
It("should handle configuration with complex body", func() {
validConfig.Body = hermes.Body{
Name: "User",
Intros: []string{"Line 1", "Line 2"},
Outros: []string{"Goodbye"},
Actions: []hermes.Action{
{
Instructions: "Click below",
Button: hermes.Button{
Text: "Action",
Link: "https://example.com/action",
},
},
},
Dictionary: []hermes.Entry{
{Key: "Key", Value: "Value"},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{{Key: "Col1", Value: "Val1"}},
},
}},
}
mailer := validConfig.NewMailer()
body := mailer.GetBody()
Expect(body.Name).To(Equal("User"))
Expect(body.Intros).To(HaveLen(2))
Expect(body.Outros).To(HaveLen(1))
Expect(body.Actions).To(HaveLen(1))
Expect(body.Dictionary).To(HaveLen(1))
Expect(body.Tables[0].Data).To(HaveLen(1))
})
})
})
Describe("Configuration Edge Cases", func() {
Context("when handling special characters", func() {
It("should handle special characters in text fields", func() {
validConfig.Name = "Company™ & Co. <>"
validConfig.Copyright = "© 2024 • All Rights Reserved"
validConfig.TroubleText = "Questions? →"
err := validConfig.Validate()
Expect(err).To(BeNil())
mailer := validConfig.NewMailer()
Expect(mailer.GetName()).To(ContainSubstring("™"))
Expect(mailer.GetCopyright()).To(ContainSubstring("•"))
Expect(mailer.GetTroubleText()).To(ContainSubstring("→"))
})
})
Context("when handling long strings", func() {
It("should handle very long text values", func() {
longString := string(make([]byte, 1000))
for i := range longString {
longString = longString[:i] + "a" + longString[i+1:]
}
validConfig.Copyright = longString
mailer := validConfig.NewMailer()
Expect(len(mailer.GetCopyright())).To(Equal(1000))
})
})
})
})

127
mail/render/direction.go Normal file
View File

@@ -0,0 +1,127 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render
import (
"strings"
"github.com/go-hermes/hermes/v2"
)
// TextDirection represents the text reading direction for email content.
// This affects how the email content is laid out and displayed in email clients.
//
// Common use cases:
// - LeftToRight: For languages like English, French, Spanish, etc.
// - RightToLeft: For languages like Arabic, Hebrew, Persian, etc.
type TextDirection uint8
const (
// LeftToRight indicates left-to-right text direction.
// Used for most Western languages (English, French, Spanish, German, etc.).
LeftToRight TextDirection = iota
// RightToLeft indicates right-to-left text direction.
// Used for RTL languages (Arabic, Hebrew, Persian, Urdu, etc.).
RightToLeft
)
// getDirection converts the TextDirection enum to the corresponding hermes.TextDirection.
// This is an internal method used by the email generation process.
func (d TextDirection) getDirection() hermes.TextDirection {
switch d {
case LeftToRight:
return hermes.TDLeftToRight
case RightToLeft:
return hermes.TDRightToLeft
}
return LeftToRight.getDirection()
}
// String returns the string representation of the text direction.
//
// Returns:
// - "Left->Right" for LeftToRight
// - "Right->Left" for RightToLeft
//
// Example:
//
// dir := render.RightToLeft
// fmt.Println(dir.String()) // Output: "Right->Left"
func (d TextDirection) String() string {
switch d {
case LeftToRight:
return "Left->Right"
case RightToLeft:
return "Right->Left"
}
return LeftToRight.String()
}
// ParseTextDirection parses a text direction string and returns the corresponding TextDirection enum.
// The parsing is case-insensitive and supports multiple formats.
//
// Supported formats:
// - "ltr", "LTR" -> LeftToRight
// - "rtl", "RTL" -> RightToLeft
// - "left", "left-to-right", "Left->Right" -> LeftToRight
// - "right", "right-to-left", "Right->Left" -> RightToLeft
//
// If the direction string is not recognized or empty, LeftToRight is returned as the default.
//
// Example:
//
// dir := render.ParseTextDirection("rtl")
// dir = render.ParseTextDirection("right-to-left")
// dir = render.ParseTextDirection("RTL")
// dir = render.ParseTextDirection("unknown") // Returns LeftToRight
func ParseTextDirection(direction string) TextDirection {
d := strings.ToLower(direction)
// Check for common abbreviations first
if strings.Contains(d, "rtl") {
return RightToLeft
}
if strings.Contains(d, "ltr") {
return LeftToRight
}
l := strings.Index(d, "left")
r := strings.Index(d, "right")
// If both "left" and "right" are found, check which comes first
// "right->left" or "right-to-left" means RightToLeft
if l >= 0 && r >= 0 && r < l {
return RightToLeft
} else if r >= 0 && l < 0 {
// Only "right" found without "left" - assume RightToLeft
return RightToLeft
}
return LeftToRight
}

299
mail/render/doc.go Normal file
View File

@@ -0,0 +1,299 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
// Package render provides email template rendering functionality using the Hermes library.
// It offers a high-level API for generating both HTML and plain text email content with
// support for themes, text direction, and template variable replacement.
//
// # Overview
//
// The render package wraps the github.com/go-hermes/hermes/v2 library to provide an
// easy-to-use interface for creating professional-looking transactional emails.
// It supports multiple themes, bidirectional text, and rich content including tables,
// actions, and custom formatting.
//
// # Key Features
//
// - Multiple visual themes (Default, Flat)
// - Bidirectional text support (LTR/RTL)
// - Template variable replacement with {{variable}} syntax
// - Rich content support (tables, actions, buttons, dictionary entries)
// - HTML and plain text generation
// - Configuration via struct with validation
// - Deep cloning for concurrent operations
// - Integration with github.com/nabbar/golib/errors for structured error handling
//
// # Basic Usage
//
// Creating a simple email:
//
// mailer := render.New()
// mailer.SetName("My Company")
// mailer.SetLink("https://example.com")
// mailer.SetLogo("https://example.com/logo.png")
// mailer.SetTheme(render.ThemeFlat)
//
// body := &hermes.Body{
// Name: "John Doe",
// Intros: []string{"Welcome to our service!"},
// Outros: []string{"Thank you for signing up."},
// }
// mailer.SetBody(body)
//
// htmlBuf, err := mailer.GenerateHTML()
// textBuf, err := mailer.GeneratePlainText()
//
// # Configuration-Based Usage
//
// Using a configuration struct (suitable for JSON/YAML/TOML):
//
// config := render.Config{
// Theme: "flat",
// Direction: "ltr",
// Name: "My Company",
// Link: "https://example.com",
// Logo: "https://example.com/logo.png",
// Copyright: "© 2024 My Company",
// TroubleText: "Need help? Contact support@example.com",
// Body: hermes.Body{
// Name: "John Doe",
// Intros: []string{"Welcome!"},
// },
// }
//
// if err := config.Validate(); err != nil {
// // Handle validation error
// }
//
// mailer := config.NewMailer()
// htmlBuf, err := mailer.GenerateHTML()
//
// # Template Variable Replacement
//
// The package supports template variable replacement with the {{variable}} syntax:
//
// mailer := render.New()
// mailer.SetName("{{company}}")
//
// body := &hermes.Body{
// Name: "{{user}}",
// Intros: []string{"Your verification code is {{code}}"},
// Actions: []hermes.Action{
// {
// Instructions: "Click below to verify:",
// Button: hermes.Button{
// Text: "Verify Email",
// Link: "{{verification_link}}",
// },
// },
// },
// }
// mailer.SetBody(body)
//
// mailer.ParseData(map[string]string{
// "{{company}}": "Acme Inc",
// "{{user}}": "John Doe",
// "{{code}}": "123456",
// "{{verification_link}}": "https://example.com/verify?token=abc123",
// })
//
// htmlBuf, err := mailer.GenerateHTML()
//
// # Advanced Content
//
// Creating emails with tables and actions:
//
// body := &hermes.Body{
// Name: "John Doe",
// Intros: []string{"Here is your monthly report:"},
// Dictionary: []hermes.Entry{
// {Key: "Transaction ID", Value: "TXN-123456"},
// {Key: "Date", Value: "2024-01-15"},
// },
// Tables: []hermes.Table{{
// Data: [][]hermes.Entry{
// {
// {Key: "Item", Value: "Product A"},
// {Key: "Quantity", Value: "2"},
// {Key: "Price", Value: "$50.00"},
// },
// {
// {Key: "Item", Value: "Product B"},
// {Key: "Quantity", Value: "1"},
// {Key: "Price", Value: "$30.00"},
// },
// },
// Columns: hermes.Columns{
// CustomWidth: map[string]string{
// "Item": "50%",
// "Quantity": "20%",
// "Price": "30%",
// },
// },
// }},
// Actions: []hermes.Action{
// {
// Instructions: "View full details:",
// Button: hermes.Button{
// Text: "View Invoice",
// Link: "https://example.com/invoice/123456",
// },
// },
// },
// Outros: []string{"Thank you for your business!"},
// }
//
// mailer.SetBody(body)
// htmlBuf, err := mailer.GenerateHTML()
//
// # Themes
//
// The package supports multiple themes:
//
// - ThemeDefault: Classic, centered email design
// - ThemeFlat: Modern, minimalist email design
//
// Theme selection:
//
// mailer.SetTheme(render.ThemeFlat)
// // or
// theme := render.ParseTheme("flat")
// mailer.SetTheme(theme)
//
// # Text Direction
//
// Support for both LTR and RTL languages:
//
// // For LTR languages (English, French, Spanish, etc.)
// mailer.SetTextDirection(render.LeftToRight)
//
// // For RTL languages (Arabic, Hebrew, Persian, etc.)
// mailer.SetTextDirection(render.RightToLeft)
//
// // Or parse from string
// direction := render.ParseTextDirection("rtl")
// mailer.SetTextDirection(direction)
//
// # Thread Safety
//
// Mailer instances are not thread-safe. For concurrent operations, use Clone():
//
// baseMailer := render.New()
// baseMailer.SetName("My Company")
// baseMailer.SetTheme(render.ThemeFlat)
//
// // Create independent copies for concurrent use
// var wg sync.WaitGroup
// for i := 0; i < 10; i++ {
// wg.Add(1)
// go func(index int) {
// defer wg.Done()
// mailer := baseMailer.Clone()
// body := &hermes.Body{
// Name: fmt.Sprintf("User %d", index),
// }
// mailer.SetBody(body)
// htmlBuf, _ := mailer.GenerateHTML()
// // Send email...
// }(i)
// }
// wg.Wait()
//
// # Error Handling
//
// The package uses github.com/nabbar/golib/errors for structured error handling:
//
// htmlBuf, err := mailer.GenerateHTML()
// if err != nil {
// if err.Code() == render.ErrorMailerHtml {
// // Handle HTML generation error
// }
// log.Printf("Error: %v", err)
// }
//
// Error codes:
// - ErrorParamEmpty: Required parameters are missing
// - ErrorMailerConfigInvalid: Configuration validation failed
// - ErrorMailerHtml: HTML generation failed
// - ErrorMailerText: Plain text generation failed
//
// # Integration with Email Sending
//
// The generated content can be used with various email sending packages:
//
// mailer := render.New()
// // ... configure mailer ...
//
// htmlBuf, err := mailer.GenerateHTML()
// textBuf, err := mailer.GeneratePlainText()
//
// // Using standard net/smtp
// msg := "From: sender@example.com\r\n" +
// "To: recipient@example.com\r\n" +
// "Subject: Welcome\r\n" +
// "MIME-Version: 1.0\r\n" +
// "Content-Type: text/html; charset=UTF-8\r\n\r\n" +
// htmlBuf.String()
//
// // Or use github.com/nabbar/golib/mail/smtp for full SMTP support
//
// # Dependencies
//
// This package depends on:
// - github.com/go-hermes/hermes/v2: Email template rendering engine
// - github.com/nabbar/golib/errors: Structured error handling
// - github.com/go-playground/validator/v10: Configuration validation
//
// # Performance Considerations
//
// - Email generation typically takes 1-5ms depending on content complexity
// - Clone operations are fast (~1µs for simple, ~10µs for complex content)
// - Template variable replacement is performed in-place with O(n*m) complexity
// - CSS inlining (default) adds processing time but improves email client compatibility
//
// # Best Practices
//
// - Always validate configuration before creating a mailer from Config
// - Use Clone() for concurrent operations to avoid race conditions
// - Generate both HTML and plain text versions for better compatibility
// - Test emails in multiple email clients (Gmail, Outlook, Apple Mail, etc.)
// - Keep logo images under 200KB for faster loading
// - Use HTTPS URLs for all links and images
// - Include clear call-to-action buttons with descriptive text
// - Provide a plain text alternative for accessibility
//
// # Related Packages
//
// For complete email workflow:
// - github.com/nabbar/golib/mail/smtp: SMTP client for sending emails
// - github.com/nabbar/golib/mail/sender: High-level email sending with attachments
// - github.com/nabbar/golib/errors: Error handling and logging
//
// # References
//
// For more information on email body structure and available fields:
// - Hermes documentation: https://github.com/go-hermes/hermes
// - Email best practices: https://www.campaignmonitor.com/best-practices/
package render

113
mail/render/email.go Normal file
View File

@@ -0,0 +1,113 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render
import "github.com/go-hermes/hermes/v2"
// email is the internal implementation of the Mailer interface.
// It encapsulates all configuration and content needed to generate emails.
//
// Fields:
// - p: Product information (name, logo, link, copyright, trouble text)
// - b: Email body content (intros, outros, tables, actions, etc.)
// - t: Visual theme for the email template
// - d: Text direction (LTR or RTL)
// - c: CSS inlining control (true = disable inlining)
type email struct {
p hermes.Product // Product/company information
b *hermes.Body // Email body content and structure
t Themes // Visual theme selection
d TextDirection // Text direction (LTR/RTL)
c bool // CSS inlining disabled flag
}
func (e *email) SetTheme(t Themes) {
e.t = t
}
func (e *email) GetTheme() Themes {
return e.t
}
func (e *email) SetTextDirection(d TextDirection) {
e.d = d
}
func (e *email) GetTextDirection() TextDirection {
return e.d
}
func (e *email) SetBody(b *hermes.Body) {
e.b = b
}
func (e *email) GetBody() *hermes.Body {
return e.b
}
func (e *email) SetCSSInline(disable bool) {
e.c = disable
}
func (e *email) SetName(name string) {
e.p.Name = name
}
func (e *email) GetName() string {
return e.p.Name
}
func (e *email) SetCopyright(copy string) {
e.p.Copyright = copy
}
func (e *email) GetCopyright() string {
return e.p.Copyright
}
func (e *email) SetLink(link string) {
e.p.Link = link
}
func (e *email) GetLink() string {
return e.p.Link
}
func (e *email) SetLogo(logoUrl string) {
e.p.Logo = logoUrl
}
func (e *email) GetLogo() string {
return e.p.Logo
}
func (e *email) SetTroubleText(text string) {
e.p.TroubleText = text
}
func (e *email) GetTroubleText() string {
return e.p.TroubleText
}

90
mail/render/error.go Normal file
View File

@@ -0,0 +1,90 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*
*/
package render
import (
"fmt"
liberr "github.com/nabbar/golib/errors"
)
// Error codes for the render package.
// These codes are used with the github.com/nabbar/golib/errors package
// to provide structured error handling.
//
// All error codes start from liberr.MinPkgMailer to avoid conflicts
// with other packages in the golib ecosystem.
const (
// ErrorParamEmpty indicates that required parameters are missing or empty.
// This error is returned when mandatory configuration or data is not provided.
ErrorParamEmpty liberr.CodeError = iota + liberr.MinPkgMailer
// ErrorMailerConfigInvalid indicates that the mailer configuration is invalid.
// This error is returned by Config.Validate() when validation fails.
ErrorMailerConfigInvalid
// ErrorMailerHtml indicates a failure during HTML email generation.
// This typically occurs when the Hermes library encounters an error
// while processing the email template.
ErrorMailerHtml
// ErrorMailerText indicates a failure during plain text email generation.
// This typically occurs when the Hermes library encounters an error
// while converting the email to plain text format.
ErrorMailerText
)
func init() {
if liberr.ExistInMapMessage(ErrorParamEmpty) {
panic(fmt.Errorf("error code collision with package golib/mailer"))
}
liberr.RegisterIdFctMessage(ErrorParamEmpty, getMessage)
}
// getMessage returns the human-readable error message for a given error code.
// This function is registered with the error system during package initialization
// and is called automatically when formatting errors.
//
// Parameters:
// - code: The error code to get the message for
//
// Returns:
// - The human-readable error message, or liberr.NullMessage if the code is unknown
func getMessage(code liberr.CodeError) (message string) {
switch code {
case ErrorParamEmpty:
return "given parameters is empty"
case ErrorMailerConfigInvalid:
return "config of mailer is invalid"
case ErrorMailerHtml:
return "cannot generate html content"
case ErrorMailerText:
return "cannot generate plain text content"
}
return liberr.NullMessage
}

260
mail/render/interface.go Normal file
View File

@@ -0,0 +1,260 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render
import (
"bytes"
"github.com/go-hermes/hermes/v2"
liberr "github.com/nabbar/golib/errors"
)
// Mailer defines the interface for generating and managing email templates.
// It provides methods to configure email properties, manage template data,
// and generate both HTML and plain text versions of emails.
//
// The Mailer uses the github.com/go-hermes/hermes/v2 library for template
// rendering and supports features like:
// - Theme selection (Default, Flat)
// - Text direction (LTR, RTL)
// - Template variable replacement
// - Product information (name, logo, link, copyright)
// - Rich body content (intros, outros, tables, actions)
//
// Thread Safety:
// Mailer instances are not thread-safe. Use Clone() to create independent
// copies for concurrent operations.
//
// Example:
//
// mailer := render.New()
// mailer.SetName("My Company")
// mailer.SetTheme(render.ThemeFlat)
// body := &hermes.Body{
// Name: "John Doe",
// Intros: []string{"Welcome to our service!"},
// }
// mailer.SetBody(body)
// htmlBuf, err := mailer.GenerateHTML()
type Mailer interface {
// Clone creates a deep copy of the Mailer instance.
// All slices and nested structures are copied independently to avoid
// shared references, making the clone safe for concurrent use.
Clone() Mailer
// SetTheme sets the visual theme for the email template.
// Available themes: ThemeDefault, ThemeFlat
SetTheme(t Themes)
// GetTheme returns the currently configured theme.
GetTheme() Themes
// SetTextDirection sets the text direction for the email content.
// Use LeftToRight for LTR languages, RightToLeft for RTL languages.
SetTextDirection(d TextDirection)
// GetTextDirection returns the currently configured text direction.
GetTextDirection() TextDirection
// SetBody sets the email body content.
// See github.com/go-hermes/hermes/v2 Body for structure details.
SetBody(b *hermes.Body)
// GetBody returns the current email body content.
GetBody() *hermes.Body
// SetCSSInline controls CSS inlining in HTML output.
// Set to true to disable CSS inlining (useful for some email clients).
SetCSSInline(disable bool)
// SetName sets the product/company name displayed in the email.
SetName(name string)
// GetName returns the configured product/company name.
GetName() string
// SetCopyright sets the copyright text displayed in the email footer.
SetCopyright(copy string)
// GetCopyright returns the configured copyright text.
GetCopyright() string
// SetLink sets the primary product/company link (usually a URL).
SetLink(link string)
// GetLink returns the configured product/company link.
GetLink() string
// SetLogo sets the URL of the logo image displayed in the email header.
SetLogo(logoUrl string)
// GetLogo returns the configured logo URL.
GetLogo() string
// SetTroubleText sets the help/support text displayed when actions fail.
// Example: "Having trouble? Contact support@example.com"
SetTroubleText(text string)
// GetTroubleText returns the configured trouble text.
GetTroubleText() string
// ParseData replaces template variables in all email content.
// Variables in the format "{{key}}" are replaced with corresponding values.
// This affects all text fields including product info, body content, tables, and actions.
//
// Example:
// data := map[string]string{
// "{{user}}": "John Doe",
// "{{code}}": "123456",
// }
// mailer.ParseData(data)
ParseData(data map[string]string)
// GenerateHTML generates the HTML version of the email.
// Returns a buffer containing the complete HTML document.
GenerateHTML() (*bytes.Buffer, liberr.Error)
// GeneratePlainText generates the plain text version of the email.
// Returns a buffer containing formatted plain text content.
GeneratePlainText() (*bytes.Buffer, liberr.Error)
}
// New creates a new Mailer instance with default configuration.
// The default configuration includes:
// - Theme: ThemeDefault
// - Text Direction: LeftToRight
// - CSS Inlining: Enabled
// - Empty product information and body content
//
// Example:
//
// mailer := render.New()
// mailer.SetName("My Company")
// mailer.SetLink("https://example.com")
func New() Mailer {
return &email{
t: ThemeDefault,
d: LeftToRight,
p: hermes.Product{
Name: "",
Link: "",
Logo: "",
Copyright: "",
TroubleText: "",
},
b: &hermes.Body{},
c: false,
}
}
// Clone creates a deep copy of the email instance.
// This method performs a complete deep copy of all fields, including:
// - All string slices (Intros, Outros)
// - Dictionary entries
// - Table data and column configurations
// - Actions with buttons
//
// The cloned instance is completely independent and safe for concurrent use.
// Modifications to the clone will not affect the original instance.
//
// Example:
//
// original := render.New()
// original.SetName("Company")
// clone := original.Clone()
// clone.SetName("Other Company") // Does not affect original
func (e *email) Clone() Mailer {
// Deep copy of slices to avoid shared references
intros := make([]string, len(e.b.Intros))
copy(intros, e.b.Intros)
outros := make([]string, len(e.b.Outros))
copy(outros, e.b.Outros)
dictionary := make([]hermes.Entry, len(e.b.Dictionary))
copy(dictionary, e.b.Dictionary)
actions := make([]hermes.Action, len(e.b.Actions))
copy(actions, e.b.Actions)
// Deep copy tables
var tables []hermes.Table
if e.b.Tables != nil {
tables = make([]hermes.Table, len(e.b.Tables))
for i, table := range e.b.Tables {
// Deep copy table data
var tableData [][]hermes.Entry
if table.Data != nil {
tableData = make([][]hermes.Entry, len(table.Data))
for j, row := range table.Data {
tableData[j] = make([]hermes.Entry, len(row))
copy(tableData[j], row)
}
}
// Deep copy table columns
var tableColumns hermes.Columns
if table.Columns.CustomWidth != nil {
tableColumns.CustomWidth = make(map[string]string, len(table.Columns.CustomWidth))
for k, v := range table.Columns.CustomWidth {
tableColumns.CustomWidth[k] = v
}
}
if table.Columns.CustomAlignment != nil {
tableColumns.CustomAlignment = make(map[string]string, len(table.Columns.CustomAlignment))
for k, v := range table.Columns.CustomAlignment {
tableColumns.CustomAlignment[k] = v
}
}
tables[i] = hermes.Table{
Title: table.Title,
Data: tableData,
Columns: tableColumns,
Class: table.Class,
TitleUnsafe: table.TitleUnsafe,
Footer: table.Footer,
FooterUnsafe: table.FooterUnsafe,
}
}
}
return &email{
p: hermes.Product{
Name: e.p.Name,
Link: e.p.Link,
Logo: e.p.Logo,
Copyright: e.p.Copyright,
TroubleText: e.p.TroubleText,
},
b: &hermes.Body{
Name: e.b.Name,
Intros: intros,
Dictionary: dictionary,
Tables: tables,
Actions: actions,
Outros: outros,
Greeting: e.b.Greeting,
Signature: e.b.Signature,
Title: e.b.Title,
FreeMarkdown: e.b.FreeMarkdown,
},
t: e.t,
d: e.d,
c: e.c,
}
}

View File

@@ -0,0 +1,274 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"github.com/go-hermes/hermes/v2"
"github.com/nabbar/golib/mail/render"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Mailer Interface", func() {
var mailer render.Mailer
BeforeEach(func() {
mailer = render.New()
})
Describe("Creation", func() {
Context("when creating a new mailer", func() {
It("should create with default values", func() {
Expect(mailer).ToNot(BeNil())
Expect(mailer.GetTheme()).To(Equal(render.ThemeDefault))
Expect(mailer.GetTextDirection()).To(Equal(render.LeftToRight))
Expect(mailer.GetName()).To(BeEmpty())
Expect(mailer.GetLink()).To(BeEmpty())
Expect(mailer.GetLogo()).To(BeEmpty())
Expect(mailer.GetCopyright()).To(BeEmpty())
Expect(mailer.GetTroubleText()).To(BeEmpty())
Expect(mailer.GetBody()).ToNot(BeNil())
})
})
})
Describe("Theme Management", func() {
Context("when setting and getting theme", func() {
It("should set and get default theme", func() {
mailer.SetTheme(render.ThemeDefault)
Expect(mailer.GetTheme()).To(Equal(render.ThemeDefault))
})
It("should set and get flat theme", func() {
mailer.SetTheme(render.ThemeFlat)
Expect(mailer.GetTheme()).To(Equal(render.ThemeFlat))
})
})
})
Describe("Text Direction Management", func() {
Context("when setting and getting text direction", func() {
It("should set and get left to right", func() {
mailer.SetTextDirection(render.LeftToRight)
Expect(mailer.GetTextDirection()).To(Equal(render.LeftToRight))
})
It("should set and get right to left", func() {
mailer.SetTextDirection(render.RightToLeft)
Expect(mailer.GetTextDirection()).To(Equal(render.RightToLeft))
})
})
})
Describe("Product Information", func() {
Context("when setting product details", func() {
It("should set and get name", func() {
mailer.SetName("Test Company")
Expect(mailer.GetName()).To(Equal("Test Company"))
})
It("should set and get link", func() {
mailer.SetLink("https://example.com")
Expect(mailer.GetLink()).To(Equal("https://example.com"))
})
It("should set and get logo", func() {
mailer.SetLogo("https://example.com/logo.png")
Expect(mailer.GetLogo()).To(Equal("https://example.com/logo.png"))
})
It("should set and get copyright", func() {
mailer.SetCopyright("© 2024 Test Company")
Expect(mailer.GetCopyright()).To(Equal("© 2024 Test Company"))
})
It("should set and get trouble text", func() {
mailer.SetTroubleText("Having trouble? Contact support")
Expect(mailer.GetTroubleText()).To(Equal("Having trouble? Contact support"))
})
})
Context("when setting empty values", func() {
It("should handle empty strings", func() {
mailer.SetName("")
mailer.SetLink("")
mailer.SetLogo("")
mailer.SetCopyright("")
mailer.SetTroubleText("")
Expect(mailer.GetName()).To(BeEmpty())
Expect(mailer.GetLink()).To(BeEmpty())
Expect(mailer.GetLogo()).To(BeEmpty())
Expect(mailer.GetCopyright()).To(BeEmpty())
Expect(mailer.GetTroubleText()).To(BeEmpty())
})
})
})
Describe("Body Management", func() {
Context("when setting and getting body", func() {
It("should set and get body", func() {
body := &hermes.Body{
Name: "John Doe",
Intros: []string{"Welcome!"},
Greeting: "Hello",
Signature: "Best regards",
Title: "Test Email",
}
mailer.SetBody(body)
retrievedBody := mailer.GetBody()
Expect(retrievedBody).ToNot(BeNil())
Expect(retrievedBody.Name).To(Equal("John Doe"))
Expect(retrievedBody.Intros).To(HaveLen(1))
Expect(retrievedBody.Intros[0]).To(Equal("Welcome!"))
Expect(retrievedBody.Greeting).To(Equal("Hello"))
Expect(retrievedBody.Signature).To(Equal("Best regards"))
Expect(retrievedBody.Title).To(Equal("Test Email"))
})
})
})
Describe("CSS Inline", func() {
Context("when setting CSS inline option", func() {
It("should set disable CSS inline to true", func() {
mailer.SetCSSInline(true)
// No getter for this, but it should not panic
})
It("should set disable CSS inline to false", func() {
mailer.SetCSSInline(false)
// No getter for this, but it should not panic
})
})
})
Describe("Clone", func() {
Context("when cloning a mailer", func() {
BeforeEach(func() {
mailer.SetName("Original Name")
mailer.SetLink("https://original.com")
mailer.SetLogo("https://original.com/logo.png")
mailer.SetCopyright("© Original")
mailer.SetTroubleText("Original trouble")
mailer.SetTheme(render.ThemeFlat)
mailer.SetTextDirection(render.RightToLeft)
body := &hermes.Body{
Name: "Original Body",
Intros: []string{"Intro 1", "Intro 2"},
Outros: []string{"Outro 1"},
Dictionary: []hermes.Entry{
{Key: "Key1", Value: "Value1"},
},
Actions: []hermes.Action{
{
Instructions: "Click the button",
Button: hermes.Button{
Text: "Click me",
Link: "https://example.com",
},
},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{{Key: "Col1", Value: "Val1"}},
},
Columns: hermes.Columns{
CustomWidth: map[string]string{"col1": "100px"},
},
}},
}
mailer.SetBody(body)
})
It("should create an independent copy", func() {
clone := mailer.Clone()
Expect(clone).ToNot(BeNil())
Expect(clone.GetName()).To(Equal(mailer.GetName()))
Expect(clone.GetLink()).To(Equal(mailer.GetLink()))
Expect(clone.GetLogo()).To(Equal(mailer.GetLogo()))
Expect(clone.GetCopyright()).To(Equal(mailer.GetCopyright()))
Expect(clone.GetTroubleText()).To(Equal(mailer.GetTroubleText()))
Expect(clone.GetTheme()).To(Equal(mailer.GetTheme()))
Expect(clone.GetTextDirection()).To(Equal(mailer.GetTextDirection()))
})
It("should have independent body slices", func() {
clone := mailer.Clone()
// Modify clone's intros
cloneBody := clone.GetBody()
cloneBody.Intros = append(cloneBody.Intros, "New Intro")
// Original should not be affected
originalBody := mailer.GetBody()
Expect(originalBody.Intros).To(HaveLen(2))
Expect(cloneBody.Intros).To(HaveLen(3))
})
It("should have independent dictionary entries", func() {
clone := mailer.Clone()
// Modify clone's dictionary
cloneBody := clone.GetBody()
cloneBody.Dictionary[0].Value = "Modified Value"
// Original should not be affected
originalBody := mailer.GetBody()
Expect(originalBody.Dictionary[0].Value).To(Equal("Value1"))
Expect(cloneBody.Dictionary[0].Value).To(Equal("Modified Value"))
})
It("should have independent table data", func() {
clone := mailer.Clone()
// Modify clone's table data
cloneBody := clone.GetBody()
cloneBody.Tables[0].Data[0][0].Value = "Modified"
// Original should not be affected
originalBody := mailer.GetBody()
Expect(originalBody.Tables[0].Data[0][0].Value).To(Equal("Val1"))
Expect(cloneBody.Tables[0].Data[0][0].Value).To(Equal("Modified"))
})
It("should handle nil slices in body", func() {
emptyMailer := render.New()
emptyMailer.SetBody(&hermes.Body{
Name: "Test",
})
clone := emptyMailer.Clone()
Expect(clone).ToNot(BeNil())
Expect(clone.GetBody()).ToNot(BeNil())
})
})
})
})

201
mail/render/render.go Normal file
View File

@@ -0,0 +1,201 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render
import (
"bytes"
"strings"
"github.com/go-hermes/hermes/v2"
liberr "github.com/nabbar/golib/errors"
)
// ParseData replaces all template variables in the email content with their corresponding values.
// Template variables should be in the format "{{key}}" and will be replaced with the
// corresponding value from the data map.
//
// This method affects all textual content including:
// - Product information (name, link, logo, copyright, trouble text)
// - Body text fields (name, greeting, signature, title, free markdown)
// - Intro and outro texts
// - Dictionary entries (keys and values)
// - Table data (keys and values)
// - Actions (instructions, invite codes, button text, links, and colors)
//
// The replacement is performed in-place, modifying the email instance.
//
// Example:
//
// mailer := render.New()
// mailer.SetName("{{company}}")
// body := &hermes.Body{
// Name: "{{user}}",
// Intros: []string{"Your code is {{code}}"},
// }
// mailer.SetBody(body)
// mailer.ParseData(map[string]string{
// "{{company}}": "Acme Inc",
// "{{user}}": "John Doe",
// "{{code}}": "123456",
// })
func (e *email) ParseData(data map[string]string) {
for k, v := range data {
e.p.Copyright = strings.ReplaceAll(e.p.Copyright, k, v)
e.p.Link = strings.ReplaceAll(e.p.Link, k, v)
e.p.Logo = strings.ReplaceAll(e.p.Logo, k, v)
e.p.Name = strings.ReplaceAll(e.p.Name, k, v)
e.p.TroubleText = strings.ReplaceAll(e.p.TroubleText, k, v)
e.b.Greeting = strings.ReplaceAll(e.b.Greeting, k, v)
e.b.Name = strings.ReplaceAll(e.b.Name, k, v)
e.b.Signature = strings.ReplaceAll(e.b.Signature, k, v)
e.b.Title = strings.ReplaceAll(e.b.Title, k, v)
e.b.FreeMarkdown = hermes.Markdown(strings.ReplaceAll(string(e.b.FreeMarkdown), k, v))
for i := range e.b.Intros {
e.b.Intros[i] = strings.ReplaceAll(e.b.Intros[i], k, v)
}
for i := range e.b.Outros {
e.b.Outros[i] = strings.ReplaceAll(e.b.Outros[i], k, v)
}
for i := range e.b.Dictionary {
e.b.Dictionary[i].Key = strings.ReplaceAll(e.b.Dictionary[i].Key, k, v)
e.b.Dictionary[i].Value = strings.ReplaceAll(e.b.Dictionary[i].Value, k, v)
}
for t := range e.b.Tables {
for i := range e.b.Tables[t].Data {
for j := range e.b.Tables[t].Data[i] {
e.b.Tables[t].Data[i][j].Key = strings.ReplaceAll(e.b.Tables[t].Data[i][j].Key, k, v)
e.b.Tables[t].Data[i][j].Value = strings.ReplaceAll(e.b.Tables[t].Data[i][j].Value, k, v)
}
}
}
for i := range e.b.Actions {
e.b.Actions[i].Instructions = strings.ReplaceAll(e.b.Actions[i].Instructions, k, v)
e.b.Actions[i].InviteCode = strings.ReplaceAll(e.b.Actions[i].InviteCode, k, v)
e.b.Actions[i].Button.Link = strings.ReplaceAll(e.b.Actions[i].Button.Link, k, v)
e.b.Actions[i].Button.Text = strings.ReplaceAll(e.b.Actions[i].Button.Text, k, v)
e.b.Actions[i].Button.Color = strings.ReplaceAll(e.b.Actions[i].Button.Color, k, v)
e.b.Actions[i].Button.TextColor = strings.ReplaceAll(e.b.Actions[i].Button.TextColor, k, v)
}
}
}
// GenerateHTML generates the HTML version of the email.
// It uses the configured theme, text direction, and all content to create
// a complete HTML email document ready to be sent.
//
// Returns:
// - A buffer containing the complete HTML document
// - An error if the HTML generation fails
//
// The generated HTML includes inline CSS (unless disabled via SetCSSInline)
// and is optimized for email client compatibility.
//
// Example:
//
// mailer := render.New()
// mailer.SetName("My Company")
// body := &hermes.Body{Name: "User", Intros: []string{"Welcome!"}}
// mailer.SetBody(body)
// htmlBuf, err := mailer.GenerateHTML()
// if err == nil {
// // Send htmlBuf.String() via email
// }
func (e *email) GenerateHTML() (*bytes.Buffer, liberr.Error) {
return e.generated(e.genHtml)
}
// GeneratePlainText generates the plain text version of the email.
// It creates a formatted text version of the email content without any HTML.
//
// Returns:
// - A buffer containing the formatted plain text
// - An error if the text generation fails
//
// The plain text version includes all content formatted in a readable way
// suitable for email clients that don't support HTML or for multipart emails.
//
// Example:
//
// mailer := render.New()
// body := &hermes.Body{Name: "User", Intros: []string{"Welcome!"}}
// mailer.SetBody(body)
// textBuf, err := mailer.GeneratePlainText()
// if err == nil {
// // Send textBuf.String() as plain text
// }
func (e *email) GeneratePlainText() (*bytes.Buffer, liberr.Error) {
return e.generated(e.genText)
}
// generated is the internal method that handles email generation.
// It creates a Hermes instance with the current configuration and calls
// the provided generation function (either HTML or plain text).
//
// Parameters:
// - f: The generation function (genHtml or genText)
//
// Returns:
// - A buffer containing the generated email content
// - An error if generation fails
func (e email) generated(f func(h hermes.Hermes, m hermes.Email) (string, error)) (*bytes.Buffer, liberr.Error) {
var buf = bytes.NewBuffer(make([]byte, 0))
h := hermes.Hermes{
Theme: e.t.getHermes(),
TextDirection: e.d.getDirection(),
Product: e.p,
DisableCSSInlining: e.c,
}
if p, e := f(h, hermes.Email{
Body: *e.b,
}); e != nil {
return nil, ErrorMailerText.Error(e)
} else {
buf.WriteString(p)
}
return buf, nil
}
// genText is a wrapper around the Hermes GeneratePlainText method.
// It generates the plain text version of an email using the Hermes library.
func (e *email) genText(h hermes.Hermes, m hermes.Email) (string, error) {
return h.GeneratePlainText(m)
}
// genHtml is a wrapper around the Hermes GenerateHTML method.
// It generates the HTML version of an email using the Hermes library.
func (e *email) genHtml(h hermes.Hermes, m hermes.Email) (string, error) {
return h.GenerateHTML(m)
}

View File

@@ -0,0 +1,38 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestRender(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Mail Render Suite")
}

654
mail/render/render_test.go Normal file
View File

@@ -0,0 +1,654 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"strings"
"github.com/go-hermes/hermes/v2"
"github.com/nabbar/golib/mail/render"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Email Rendering", func() {
var mailer render.Mailer
BeforeEach(func() {
mailer = render.New()
mailer.SetName("Test Company")
mailer.SetLink("https://example.com")
mailer.SetLogo("https://example.com/logo.png")
mailer.SetCopyright("© 2024 Test Company")
mailer.SetTroubleText("Having trouble? Contact us")
})
Describe("HTML Generation", func() {
Context("with basic content", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "John Doe",
Intros: []string{"Welcome to our service!"},
Outros: []string{"Thank you for using our service."},
}
mailer.SetBody(body)
})
It("should generate valid HTML", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
Expect(buf.Len()).To(BeNumerically(">", 0))
html := buf.String()
Expect(html).To(ContainSubstring("<!DOCTYPE html"))
Expect(html).To(ContainSubstring("John Doe"))
Expect(html).To(ContainSubstring("Welcome to our service!"))
})
It("should include product information in HTML", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
Expect(html).To(ContainSubstring("Test Company"))
Expect(html).To(ContainSubstring("© 2024 Test Company"))
})
})
Context("with complex content", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "User",
Intros: []string{"Here is your activity report:"},
Dictionary: []hermes.Entry{
{Key: "Transaction ID", Value: "123456"},
{Key: "Amount", Value: "$99.99"},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{
{Key: "Item", Value: "Product A"},
{Key: "Price", Value: "$49.99"},
},
{
{Key: "Item", Value: "Product B"},
{Key: "Price", Value: "$49.99"},
},
},
Columns: hermes.Columns{
CustomWidth: map[string]string{
"Item": "80%",
"Price": "20%",
},
},
}},
Actions: []hermes.Action{
{
Instructions: "To view your order, click below:",
Button: hermes.Button{
Text: "View Order",
Link: "https://example.com/order/123456",
},
},
},
Outros: []string{"Need help? Reply to this email."},
}
mailer.SetBody(body)
})
It("should generate HTML with table data", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
Expect(html).To(ContainSubstring("Product A"))
Expect(html).To(ContainSubstring("Product B"))
Expect(html).To(ContainSubstring("$49.99"))
})
It("should generate HTML with action button", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
Expect(html).To(ContainSubstring("View Order"))
Expect(html).To(ContainSubstring("https://example.com/order/123456"))
})
It("should generate HTML with dictionary entries", func() {
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
Expect(html).To(ContainSubstring("Transaction ID"))
Expect(html).To(ContainSubstring("123456"))
Expect(html).To(ContainSubstring("$99.99"))
})
})
Context("with different themes", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "User",
Intros: []string{"Test email"},
}
mailer.SetBody(body)
})
It("should generate HTML with default theme", func() {
mailer.SetTheme(render.ThemeDefault)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf.Len()).To(BeNumerically(">", 0))
})
It("should generate HTML with flat theme", func() {
mailer.SetTheme(render.ThemeFlat)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf.Len()).To(BeNumerically(">", 0))
})
})
Context("with CSS inline options", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "User",
Intros: []string{"Test"},
}
mailer.SetBody(body)
})
It("should generate HTML with CSS inline enabled", func() {
mailer.SetCSSInline(false)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
// With inline CSS, styles should be in style attributes
Expect(html).To(ContainSubstring("style="))
})
It("should generate HTML with CSS inline disabled", func() {
mailer.SetCSSInline(true)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
})
})
Describe("Plain Text Generation", func() {
Context("with basic content", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "John Doe",
Intros: []string{"Welcome to our service!"},
Outros: []string{"Thank you."},
}
mailer.SetBody(body)
})
It("should generate valid plain text", func() {
buf, err := mailer.GeneratePlainText()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
Expect(buf.Len()).To(BeNumerically(">", 0))
text := buf.String()
Expect(text).To(ContainSubstring("John Doe"))
Expect(text).To(ContainSubstring("Welcome to our service!"))
Expect(text).ToNot(ContainSubstring("<html>"))
})
})
Context("with complex content", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "User",
Intros: []string{"Report:"},
Dictionary: []hermes.Entry{
{Key: "ID", Value: "123"},
},
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{{Key: "Item", Value: "A"}},
},
}},
Actions: []hermes.Action{
{
Instructions: "Click here:",
Button: hermes.Button{
Text: "Action",
Link: "https://example.com",
},
},
},
}
mailer.SetBody(body)
})
It("should include all content in plain text", func() {
buf, err := mailer.GeneratePlainText()
Expect(err).To(BeNil())
text := buf.String()
Expect(text).To(ContainSubstring("User"))
Expect(text).To(ContainSubstring("Report:"))
Expect(text).To(ContainSubstring("https://example.com"))
})
})
})
Describe("ParseData", func() {
Context("with template variables", func() {
BeforeEach(func() {
body := &hermes.Body{
Name: "{{username}}",
Intros: []string{"Hello {{username}}, your code is {{code}}"},
Greeting: "Hi {{username}}",
Signature: "{{company}}",
Title: "Welcome {{username}}",
Dictionary: []hermes.Entry{
{Key: "{{key}}", Value: "{{value}}"},
},
}
mailer.SetBody(body)
mailer.SetName("{{company}}")
mailer.SetCopyright("© {{year}} {{company}}")
})
It("should replace all template variables", func() {
data := map[string]string{
"{{username}}": "John Doe",
"{{code}}": "123456",
"{{company}}": "Test Inc",
"{{year}}": "2024",
"{{key}}": "Key1",
"{{value}}": "Value1",
}
mailer.ParseData(data)
body := mailer.GetBody()
Expect(body.Name).To(Equal("John Doe"))
Expect(body.Intros[0]).To(Equal("Hello John Doe, your code is 123456"))
Expect(body.Greeting).To(Equal("Hi John Doe"))
Expect(body.Signature).To(Equal("Test Inc"))
Expect(body.Title).To(Equal("Welcome John Doe"))
Expect(body.Dictionary[0].Key).To(Equal("Key1"))
Expect(body.Dictionary[0].Value).To(Equal("Value1"))
Expect(mailer.GetName()).To(Equal("Test Inc"))
Expect(mailer.GetCopyright()).To(Equal("© 2024 Test Inc"))
})
It("should handle multiple replacements in same string", func() {
body := &hermes.Body{
Intros: []string{"{{var1}} and {{var2}} and {{var1}}"},
}
mailer.SetBody(body)
data := map[string]string{
"{{var1}}": "First",
"{{var2}}": "Second",
}
mailer.ParseData(data)
body = mailer.GetBody()
Expect(body.Intros[0]).To(Equal("First and Second and First"))
})
})
Context("with table and action replacements", func() {
BeforeEach(func() {
body := &hermes.Body{
Tables: []hermes.Table{{
Data: [][]hermes.Entry{
{
{Key: "{{header1}}", Value: "{{value1}}"},
{Key: "{{header2}}", Value: "{{value2}}"},
},
},
}},
Actions: []hermes.Action{
{
Instructions: "{{instruction}}",
InviteCode: "{{code}}",
Button: hermes.Button{
Text: "{{buttonText}}",
Link: "{{buttonLink}}",
Color: "{{color}}",
TextColor: "{{textColor}}",
},
},
},
}
mailer.SetBody(body)
})
It("should replace variables in table data", func() {
data := map[string]string{
"{{header1}}": "Name",
"{{header2}}": "Email",
"{{value1}}": "John",
"{{value2}}": "john@example.com",
}
mailer.ParseData(data)
body := mailer.GetBody()
Expect(body.Tables[0].Data[0][0].Key).To(Equal("Name"))
Expect(body.Tables[0].Data[0][0].Value).To(Equal("John"))
Expect(body.Tables[0].Data[0][1].Key).To(Equal("Email"))
Expect(body.Tables[0].Data[0][1].Value).To(Equal("john@example.com"))
})
It("should replace variables in actions", func() {
data := map[string]string{
"{{instruction}}": "Click below",
"{{code}}": "INVITE123",
"{{buttonText}}": "Accept",
"{{buttonLink}}": "https://example.com/accept",
"{{color}}": "#007bff",
"{{textColor}}": "#ffffff",
}
mailer.ParseData(data)
body := mailer.GetBody()
action := body.Actions[0]
Expect(action.Instructions).To(Equal("Click below"))
Expect(action.InviteCode).To(Equal("INVITE123"))
Expect(action.Button.Text).To(Equal("Accept"))
Expect(action.Button.Link).To(Equal("https://example.com/accept"))
Expect(action.Button.Color).To(Equal("#007bff"))
Expect(action.Button.TextColor).To(Equal("#ffffff"))
})
})
Context("with markdown content", func() {
It("should replace variables in free markdown", func() {
body := &hermes.Body{
FreeMarkdown: hermes.Markdown("# {{title}}\n\nHello {{name}}!"),
}
mailer.SetBody(body)
data := map[string]string{
"{{title}}": "Welcome",
"{{name}}": "User",
}
mailer.ParseData(data)
body = mailer.GetBody()
markdown := string(body.FreeMarkdown)
Expect(markdown).To(ContainSubstring("# Welcome"))
Expect(markdown).To(ContainSubstring("Hello User!"))
})
})
Context("with empty or nil data", func() {
It("should handle empty data map", func() {
body := &hermes.Body{
Name: "Original",
}
mailer.SetBody(body)
mailer.ParseData(map[string]string{})
// Should not modify anything
Expect(mailer.GetBody().Name).To(Equal("Original"))
})
It("should handle nil slices gracefully", func() {
body := &hermes.Body{
Name: "Test",
}
mailer.SetBody(body)
// Should not panic
mailer.ParseData(map[string]string{"{{test}}": "value"})
Expect(mailer.GetBody().Name).To(Equal("Test"))
})
})
})
Describe("Integration", func() {
Context("when using complete workflow", func() {
It("should create, configure, parse and generate email", func() {
// Configure
mailer.SetName("{{company}}")
mailer.SetTheme(render.ThemeFlat)
// Set body
body := &hermes.Body{
Name: "{{user}}",
Intros: []string{"Welcome {{user}}!"},
Actions: []hermes.Action{
{
Instructions: "Verify your email:",
Button: hermes.Button{
Text: "Verify",
Link: "{{link}}",
},
},
},
}
mailer.SetBody(body)
// Parse data
mailer.ParseData(map[string]string{
"{{company}}": "Test Inc",
"{{user}}": "John",
"{{link}}": "https://example.com/verify",
})
// Generate both formats
htmlBuf, htmlErr := mailer.GenerateHTML()
textBuf, textErr := mailer.GeneratePlainText()
Expect(htmlErr).To(BeNil())
Expect(textErr).To(BeNil())
Expect(htmlBuf.Len()).To(BeNumerically(">", 0))
Expect(textBuf.Len()).To(BeNumerically(">", 0))
html := htmlBuf.String()
text := textBuf.String()
Expect(html).To(ContainSubstring("John"))
Expect(html).To(ContainSubstring("Welcome John!"))
Expect(html).To(ContainSubstring("https://example.com/verify"))
Expect(text).To(ContainSubstring("John"))
Expect(text).To(ContainSubstring("Welcome John!"))
})
})
})
Describe("Error Handling", func() {
Context("with invalid configurations", func() {
It("should handle generation with minimal setup", func() {
minimalMailer := render.New()
minimalMailer.SetBody(&hermes.Body{
Name: "Test",
})
htmlBuf, htmlErr := minimalMailer.GenerateHTML()
textBuf, textErr := minimalMailer.GeneratePlainText()
// Should not error with minimal setup
Expect(htmlErr).To(BeNil())
Expect(textErr).To(BeNil())
Expect(htmlBuf).ToNot(BeNil())
Expect(textBuf).ToNot(BeNil())
})
})
})
Describe("Special Characters", func() {
Context("when content contains special characters", func() {
It("should handle HTML special characters", func() {
body := &hermes.Body{
Name: "User <>&\"'",
Intros: []string{"Test <script>alert('xss')</script>"},
}
mailer.SetBody(body)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
// Hermes should escape HTML entities
Expect(html).ToNot(ContainSubstring("<script>"))
})
It("should handle unicode characters", func() {
body := &hermes.Body{
Name: "用户 👤",
Intros: []string{"Hello 世界 🌍!"},
}
mailer.SetBody(body)
htmlBuf, htmlErr := mailer.GenerateHTML()
textBuf, textErr := mailer.GeneratePlainText()
Expect(htmlErr).To(BeNil())
Expect(textErr).To(BeNil())
Expect(htmlBuf.String()).To(ContainSubstring("用户"))
Expect(textBuf.String()).To(ContainSubstring("世界"))
})
})
})
Describe("Text Direction", func() {
Context("with different text directions", func() {
It("should generate with left-to-right direction", func() {
mailer.SetTextDirection(render.LeftToRight)
body := &hermes.Body{
Name: "User",
Intros: []string{"Test"},
}
mailer.SetBody(body)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
Expect(html).To(ContainSubstring("ltr"))
})
It("should generate with right-to-left direction", func() {
mailer.SetTextDirection(render.RightToLeft)
body := &hermes.Body{
Name: "مستخدم",
Intros: []string{"مرحبا"},
}
mailer.SetBody(body)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
html := buf.String()
Expect(html).To(ContainSubstring("rtl"))
})
})
})
Describe("Empty Content", func() {
Context("with empty body sections", func() {
It("should handle empty intros", func() {
body := &hermes.Body{
Name: "User",
Intros: []string{},
}
mailer.SetBody(body)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
It("should handle empty outros", func() {
body := &hermes.Body{
Name: "User",
Outros: []string{},
}
mailer.SetBody(body)
buf, err := mailer.GeneratePlainText()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
})
})
Describe("Long Content", func() {
Context("with very long strings", func() {
It("should handle long intro text", func() {
longText := strings.Repeat("Lorem ipsum dolor sit amet. ", 100)
body := &hermes.Body{
Name: "User",
Intros: []string{longText},
}
mailer.SetBody(body)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf.Len()).To(BeNumerically(">", len(longText)))
})
It("should handle many table rows", func() {
data := make([][]hermes.Entry, 100)
for i := range data {
data[i] = []hermes.Entry{
{Key: "Item", Value: "Row " + string(rune(i))},
}
}
body := &hermes.Body{
Name: "User",
Tables: []hermes.Table{{
Data: data,
}},
}
mailer.SetBody(body)
buf, err := mailer.GenerateHTML()
Expect(err).To(BeNil())
Expect(buf).ToNot(BeNil())
})
})
})
})

97
mail/render/themes.go Normal file
View File

@@ -0,0 +1,97 @@
/*
* MIT License
*
* Copyright (c) 2020 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render
import (
"strings"
"github.com/go-hermes/hermes/v2"
)
// Themes represents the available email template themes.
// Each theme provides a different visual style for the generated emails.
//
// The themes are based on github.com/go-hermes/hermes/v2 theme implementations.
type Themes uint8
const (
// ThemeDefault is the default theme provided by Hermes.
// It offers a classic, clean email design with a centered layout.
ThemeDefault Themes = iota
// ThemeFlat is the flat theme provided by Hermes.
// It offers a modern, minimalist email design with flat colors.
ThemeFlat
)
// getHermes converts the Themes enum to the corresponding hermes.Theme implementation.
// This is an internal method used by the email generation process.
func (t Themes) getHermes() hermes.Theme {
switch t {
case ThemeDefault:
return &hermes.Default{}
case ThemeFlat:
return &hermes.Flat{}
}
return ThemeDefault.getHermes()
}
// String returns the string representation of the theme.
// The string value matches the theme name from the Hermes library.
//
// Example:
//
// theme := render.ThemeFlat
// fmt.Println(theme.String()) // Output: "flat"
func (t Themes) String() string {
return t.getHermes().Name()
}
// ParseTheme parses a theme name string and returns the corresponding Themes enum value.
// The parsing is case-insensitive.
//
// Supported theme names:
// - "default" -> ThemeDefault
// - "flat" -> ThemeFlat
//
// If the theme name is not recognized, ThemeDefault is returned.
//
// Example:
//
// theme := render.ParseTheme("flat")
// theme = render.ParseTheme("FLAT") // Also works
// theme = render.ParseTheme("unknown") // Returns ThemeDefault
func ParseTheme(theme string) Themes {
switch strings.ToLower(theme) {
case strings.ToLower(ThemeDefault.String()):
return ThemeDefault
case strings.ToLower(ThemeFlat.String()):
return ThemeFlat
}
return ThemeDefault
}

228
mail/render/themes_test.go Normal file
View File

@@ -0,0 +1,228 @@
/*
* MIT License
*
* Copyright (c) 2024 Nicolas JUHEL
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
*/
package render_test
import (
"github.com/nabbar/golib/mail/render"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Themes", func() {
Describe("Theme Constants", func() {
Context("when using theme constants", func() {
It("should have correct default theme value", func() {
Expect(render.ThemeDefault).To(BeNumerically("==", 0))
})
It("should have correct flat theme value", func() {
Expect(render.ThemeFlat).To(BeNumerically("==", 1))
})
})
})
Describe("Theme String Conversion", func() {
Context("when converting theme to string", func() {
It("should convert default theme to string", func() {
str := render.ThemeDefault.String()
Expect(str).To(Equal("default"))
})
It("should convert flat theme to string", func() {
str := render.ThemeFlat.String()
Expect(str).To(Equal("flat"))
})
})
})
Describe("ParseTheme", func() {
Context("with valid theme names", func() {
It("should parse 'default' to ThemeDefault", func() {
theme := render.ParseTheme("default")
Expect(theme).To(Equal(render.ThemeDefault))
})
It("should parse 'Default' (mixed case) to ThemeDefault", func() {
theme := render.ParseTheme("Default")
Expect(theme).To(Equal(render.ThemeDefault))
})
It("should parse 'DEFAULT' (upper case) to ThemeDefault", func() {
theme := render.ParseTheme("DEFAULT")
Expect(theme).To(Equal(render.ThemeDefault))
})
It("should parse 'flat' to ThemeFlat", func() {
theme := render.ParseTheme("flat")
Expect(theme).To(Equal(render.ThemeFlat))
})
It("should parse 'Flat' (mixed case) to ThemeFlat", func() {
theme := render.ParseTheme("Flat")
Expect(theme).To(Equal(render.ThemeFlat))
})
It("should parse 'FLAT' (upper case) to ThemeFlat", func() {
theme := render.ParseTheme("FLAT")
Expect(theme).To(Equal(render.ThemeFlat))
})
})
Context("with invalid theme names", func() {
It("should default to ThemeDefault for unknown theme", func() {
theme := render.ParseTheme("unknown")
Expect(theme).To(Equal(render.ThemeDefault))
})
It("should default to ThemeDefault for empty string", func() {
theme := render.ParseTheme("")
Expect(theme).To(Equal(render.ThemeDefault))
})
It("should default to ThemeDefault for random string", func() {
theme := render.ParseTheme("random123")
Expect(theme).To(Equal(render.ThemeDefault))
})
})
})
})
var _ = Describe("Text Direction", func() {
Describe("Direction Constants", func() {
Context("when using direction constants", func() {
It("should have correct left to right value", func() {
Expect(render.LeftToRight).To(BeNumerically("==", 0))
})
It("should have correct right to left value", func() {
Expect(render.RightToLeft).To(BeNumerically("==", 1))
})
})
})
Describe("Direction String Conversion", func() {
Context("when converting direction to string", func() {
It("should convert LeftToRight to string", func() {
str := render.LeftToRight.String()
Expect(str).To(Equal("Left->Right"))
})
It("should convert RightToLeft to string", func() {
str := render.RightToLeft.String()
Expect(str).To(Equal("Right->Left"))
})
})
})
Describe("ParseTextDirection", func() {
Context("with left-to-right variations", func() {
It("should parse 'ltr' to LeftToRight", func() {
dir := render.ParseTextDirection("ltr")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should parse 'left-to-right' to LeftToRight", func() {
dir := render.ParseTextDirection("left-to-right")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should parse 'left' to LeftToRight", func() {
dir := render.ParseTextDirection("left")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should parse 'Left->Right' to LeftToRight", func() {
dir := render.ParseTextDirection("Left->Right")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should parse 'LTR' (upper case) to LeftToRight", func() {
dir := render.ParseTextDirection("LTR")
Expect(dir).To(Equal(render.LeftToRight))
})
})
Context("with right-to-left variations", func() {
It("should parse 'rtl' to RightToLeft", func() {
dir := render.ParseTextDirection("rtl")
Expect(dir).To(Equal(render.RightToLeft))
})
It("should parse 'right-to-left' to RightToLeft", func() {
dir := render.ParseTextDirection("right-to-left")
Expect(dir).To(Equal(render.RightToLeft))
})
It("should parse 'right' to RightToLeft", func() {
dir := render.ParseTextDirection("right")
Expect(dir).To(Equal(render.RightToLeft))
})
It("should parse 'Right->Left' to RightToLeft", func() {
dir := render.ParseTextDirection("Right->Left")
Expect(dir).To(Equal(render.RightToLeft))
})
It("should parse 'RTL' (upper case) to RightToLeft", func() {
dir := render.ParseTextDirection("RTL")
Expect(dir).To(Equal(render.RightToLeft))
})
})
Context("with edge cases", func() {
It("should default to LeftToRight for empty string", func() {
dir := render.ParseTextDirection("")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should default to LeftToRight for unknown string", func() {
dir := render.ParseTextDirection("unknown")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should parse correctly when both 'left' and 'right' are present", func() {
// "right->left" should be RightToLeft (right comes first)
dir := render.ParseTextDirection("right->left")
Expect(dir).To(Equal(render.RightToLeft))
// "left->right" should be LeftToRight (left comes first)
dir = render.ParseTextDirection("left->right")
Expect(dir).To(Equal(render.LeftToRight))
})
It("should handle strings with only 'right' as RightToLeft", func() {
dir := render.ParseTextDirection("right")
Expect(dir).To(Equal(render.RightToLeft))
})
It("should handle strings with only 'left' as LeftToRight", func() {
dir := render.ParseTextDirection("left")
Expect(dir).To(Equal(render.LeftToRight))
})
})
})
})