Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,96 @@ templates/
text, html, err := comms.Render("verify_email", data)
```

### External Templates

External templates allow you to render and send emails from template strings loaded at runtime (e.g., from a database). They are wrapped in a branded email shell automatically.

#### Quick Start

```go
// Define template (typically loaded from database)
tmpl := comms.ExternalTemplate{
SubjectTemplate: "Welcome to {{.OrgName}}",
BodyTemplate: "<h1>Hello {{.Name}}</h1><p>Welcome aboard!</p>",
TextTemplate: "Hello {{.Name}}, welcome aboard!",
}

// Optional: customize branding (nil = Kopexa defaults)
branding := &comms.Branding{
BrandName: "Acme Corp",
LogoURL: "https://acme.com/logo.png",
PrimaryColor: "#ff6600",
ButtonColor: "#cc0000",
SupportEmail: "[email protected]",
}

// Render only (for previews)
rendered, err := comms.RenderTemplate(tmpl, branding, map[string]any{
"Name": "Max",
"OrgName": "Acme Corp",
})

// Or render + send in one call
err = c.SendFromTemplate(ctx, recipient, tmpl, branding, data)
```

#### Template Syntax

Templates use Go's [template syntax](https://pkg.go.dev/text/template) with [Sprig v3](https://masterminds.github.io/sprig/) functions available.

**Body templates** (`BodyTemplate`) are rendered with `html/template` for XSS protection. **Subject** and **Text** templates use `text/template` (no HTML escaping).

#### Using Branding in Templates

Branding values are available in all templates via the `.Branding` key:

```html
<a href="{{.URL}}" style="background-color: {{.Branding.ButtonColor}}; color: {{.Branding.ButtonTextColor}}; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
Click here
</a>
```

#### Template Defaults

Templates support a `Defaults` map for base-layer variables. Call-site data takes precedence:

```go
tmpl := comms.ExternalTemplate{
BodyTemplate: "<p>Visit {{.AppURL}}</p>",
Defaults: map[string]any{"AppURL": "https://app.example.com"},
}

// Call-site data overrides defaults
data := map[string]any{"AppURL": "https://custom.example.com"}
```

#### Branding Defaults

| Field | Default |
|-------|---------|
| BrandName | Kopexa |
| PrimaryColor | #2563eb |
| BackgroundColor | #f1f5f9 |
| TextColor | #0f172a |
| ButtonColor | #2563eb |
| ButtonTextColor | #ffffff |
| LinkColor | #2563eb |
| FontFamily | Helvetica, Arial, sans-serif |
| CompanyName | Kopexa GmbH |
| SupportEmail | [email protected] |

#### Two-Phase API

For advanced use cases (previews, caching, bulk sends), use the two-phase API:

```go
// Phase 1: Render (pure, no driver needed)
rendered, err := comms.RenderTemplate(tmpl, branding, data)

// Phase 2: Send (uses configured driver)
err = c.SendRendered(ctx, recipient, rendered)
```

## Configuration

### Options
Expand Down
141 changes: 141 additions & 0 deletions branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) Kopexa GmbH
// SPDX-License-Identifier: BUSL-1.1

package comms

// Default branding values used when Branding fields are zero-valued.
const (
DefaultBrandName = "Kopexa"
DefaultPrimaryColor = "#2563eb"
DefaultBackgroundColor = "#f1f5f9"
DefaultTextColor = "#0f172a"
DefaultButtonColor = "#2563eb"
DefaultButtonTextColor = "#ffffff"
DefaultLinkColor = "#2563eb"
DefaultFontFamily = "Helvetica, Arial, sans-serif"
DefaultCompanyName = "Kopexa GmbH"
DefaultCompanyAddress = "Schauenburgerstr. 116\n24118 Kiel\nGermany"
DefaultSupportEmail = "[email protected]"
)

// Branding configures the visual appearance of the email shell.
// Zero-valued fields fall back to Kopexa defaults.
type Branding struct {
// BrandName is displayed in the email header and footer.
// Default: "Kopexa"
BrandName string

// LogoURL is the URL to the brand logo image shown in the email header.
// If empty, BrandName is displayed as styled text instead.
LogoURL string

// PrimaryColor is the primary accent color (hex) used for the header.
// Default: "#2563eb"
PrimaryColor string

// BackgroundColor is the email body background color (hex).
// Default: "#f1f5f9"
BackgroundColor string

// TextColor is the main body text color (hex).
// Default: "#0f172a"
TextColor string

// ButtonColor is the CTA button background color (hex).
// Available in body templates via {{.Branding.ButtonColor}}.
// Default: "#2563eb"
ButtonColor string

// ButtonTextColor is the CTA button text color (hex).
// Available in body templates via {{.Branding.ButtonTextColor}}.
// Default: "#ffffff"
ButtonTextColor string

// LinkColor is the link color (hex) used in the footer.
// Default: "#2563eb"
LinkColor string

// FontFamily is the CSS font-family for the email.
// Default: "Helvetica, Arial, sans-serif"
FontFamily string

// CompanyName is the legal entity name shown in the footer.
// Default: "Kopexa GmbH"
CompanyName string

// CompanyAddress is the multiline company address shown in the footer.
// Default: "Schauenburgerstr. 116\n24118 Kiel\nGermany"
CompanyAddress string

// SupportEmail is the support contact email shown in the footer.
// Default: "[email protected]"
SupportEmail string
}

// resolveBranding fills zero-valued fields in the given Branding with defaults.
// If b is nil, a Branding with all defaults is returned.
func resolveBranding(b *Branding) Branding {
if b == nil {
return Branding{
BrandName: DefaultBrandName,
PrimaryColor: DefaultPrimaryColor,
BackgroundColor: DefaultBackgroundColor,
TextColor: DefaultTextColor,
ButtonColor: DefaultButtonColor,
ButtonTextColor: DefaultButtonTextColor,
LinkColor: DefaultLinkColor,
FontFamily: DefaultFontFamily,
CompanyName: DefaultCompanyName,
CompanyAddress: DefaultCompanyAddress,
SupportEmail: DefaultSupportEmail,
}
}

resolved := *b

if resolved.BrandName == "" {
resolved.BrandName = DefaultBrandName
}

if resolved.PrimaryColor == "" {
resolved.PrimaryColor = DefaultPrimaryColor
}

if resolved.BackgroundColor == "" {
resolved.BackgroundColor = DefaultBackgroundColor
}

if resolved.TextColor == "" {
resolved.TextColor = DefaultTextColor
}

if resolved.ButtonColor == "" {
resolved.ButtonColor = DefaultButtonColor
}

if resolved.ButtonTextColor == "" {
resolved.ButtonTextColor = DefaultButtonTextColor
}

if resolved.LinkColor == "" {
resolved.LinkColor = DefaultLinkColor
}

if resolved.FontFamily == "" {
resolved.FontFamily = DefaultFontFamily
}

if resolved.CompanyName == "" {
resolved.CompanyName = DefaultCompanyName
}

if resolved.CompanyAddress == "" {
resolved.CompanyAddress = DefaultCompanyAddress
}

if resolved.SupportEmail == "" {
resolved.SupportEmail = DefaultSupportEmail
}

return resolved
}
79 changes: 79 additions & 0 deletions branding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) Kopexa GmbH
// SPDX-License-Identifier: BUSL-1.1

package comms

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestResolveBranding_Nil(t *testing.T) {
b := resolveBranding(nil)

require.Equal(t, DefaultBrandName, b.BrandName)
require.Equal(t, DefaultPrimaryColor, b.PrimaryColor)
require.Equal(t, DefaultBackgroundColor, b.BackgroundColor)
require.Equal(t, DefaultTextColor, b.TextColor)
require.Equal(t, DefaultButtonColor, b.ButtonColor)
require.Equal(t, DefaultButtonTextColor, b.ButtonTextColor)
require.Equal(t, DefaultLinkColor, b.LinkColor)
require.Equal(t, DefaultFontFamily, b.FontFamily)
require.Equal(t, DefaultCompanyName, b.CompanyName)
require.Equal(t, DefaultCompanyAddress, b.CompanyAddress)
require.Equal(t, DefaultSupportEmail, b.SupportEmail)
require.Empty(t, b.LogoURL)
}

func TestResolveBranding_Partial(t *testing.T) {
b := resolveBranding(&Branding{
BrandName: "Acme Corp",
PrimaryColor: "#ff6600",
})

require.Equal(t, "Acme Corp", b.BrandName)
require.Equal(t, "#ff6600", b.PrimaryColor)
// All other fields should be defaults
require.Equal(t, DefaultBackgroundColor, b.BackgroundColor)
require.Equal(t, DefaultTextColor, b.TextColor)
require.Equal(t, DefaultButtonColor, b.ButtonColor)
require.Equal(t, DefaultButtonTextColor, b.ButtonTextColor)
require.Equal(t, DefaultLinkColor, b.LinkColor)
require.Equal(t, DefaultFontFamily, b.FontFamily)
require.Equal(t, DefaultCompanyName, b.CompanyName)
require.Equal(t, DefaultCompanyAddress, b.CompanyAddress)
require.Equal(t, DefaultSupportEmail, b.SupportEmail)
}

func TestResolveBranding_Full(t *testing.T) {
input := &Branding{
BrandName: "Acme Corp",
LogoURL: "https://acme.com/logo.png",
PrimaryColor: "#ff6600",
BackgroundColor: "#000000",
TextColor: "#ffffff",
ButtonColor: "#cc0000",
ButtonTextColor: "#00ff00",
LinkColor: "#0000ff",
FontFamily: "Georgia, serif",
CompanyName: "Acme Corp Inc.",
CompanyAddress: "123 Main St\nNew York, NY",
SupportEmail: "[email protected]",
}

b := resolveBranding(input)

require.Equal(t, "Acme Corp", b.BrandName)
require.Equal(t, "https://acme.com/logo.png", b.LogoURL)
require.Equal(t, "#ff6600", b.PrimaryColor)
require.Equal(t, "#000000", b.BackgroundColor)
require.Equal(t, "#ffffff", b.TextColor)
require.Equal(t, "#cc0000", b.ButtonColor)
require.Equal(t, "#00ff00", b.ButtonTextColor)
require.Equal(t, "#0000ff", b.LinkColor)
require.Equal(t, "Georgia, serif", b.FontFamily)
require.Equal(t, "Acme Corp Inc.", b.CompanyName)
require.Equal(t, "123 Main St\nNew York, NY", b.CompanyAddress)
require.Equal(t, "[email protected]", b.SupportEmail)
}
Loading
Loading