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
5 changes: 1 addition & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ jobs:
--generate-notes \
dist/* \
static/confirm.md \
static/template.html \
static/theme.example.css
static/template.html

docker:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -79,5 +78,3 @@ jobs:
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest


1 change: 0 additions & 1 deletion config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ auth:

paths:
template: "./static/template.html" # HTML wrapper rendered around markdown content
theme: "./static/theme.example.css" # CSS injected into the template
confirm-mail: "./static/confirm.md" # markdown template for the double opt-in email

redirects:
Expand Down
1 change: 0 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ type Config struct {
Paths struct {
Config string `env:"CONFIG_PATH" env-default:"config.yml"`
Template string `env:"TEMPLATE_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/template.html" yaml:"template"`
Theme string `env:"THEME_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/theme.example.css" yaml:"theme"`
ConfirmMail string `env:"CONFIRM_MAIL_PATH" env-default:"https://github.com/5000K/5000mails/releases/latest/download/confirm.md" yaml:"confirm-mail"`
} `yaml:"paths"`
}
Expand Down
12 changes: 2 additions & 10 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Configuration is loaded in two passes:

YAML takes precedence over environment variables for every field that has both a `yaml:` tag and an `env:` tag. The config file path itself can only be set via `CONFIG_PATH`. If the file is not found, the server starts with environment-variable values only.

Path values (template, theme, confirm-mail) accept either a local filesystem path or an `http(s)://` URL; the server fetches remote resources at startup.
Path values (template, confirm-mail) accept either a local filesystem path or an `http(s)://` URL; the server fetches remote resources at startup.

---

Expand Down Expand Up @@ -73,16 +73,9 @@ All values accept a local path **or** an `http(s)://` URL. Remote resources are
| YAML key | Environment variable | Default (remote) | Description |
|----------------|------------------------|---------------------------------------------------------------------------------------|-------------------------------------------------------------|
| `template` | `TEMPLATE_PATH` | `https://github.com/5000K/5000mails/releases/latest/download/template.html` | HTML wrapper rendered around every markdown newsletter |
| `theme` | `THEME_PATH` | `https://github.com/5000K/5000mails/releases/latest/download/theme.example.css` | CSS injected into the HTML template |
| `confirm-mail` | `CONFIRM_MAIL_PATH` | `https://github.com/5000K/5000mails/releases/latest/download/confirm.md` | Markdown template for the double opt-in confirmation email |

The `confirm-mail` template receives the following template variables:

| Variable | Value |
|-------------------|----------------------------------------------|
| `ConfirmationURL` | Full URL the subscriber must visit to confirm |
| `Name` | Subscriber display name |
| `Email` | Subscriber email address |
See [docs/TEMPLATE.md](TEMPLATE.md) for a full reference of template variables available in the `confirm-mail` template and all other mail contexts.

---

Expand Down Expand Up @@ -125,7 +118,6 @@ auth:

paths:
template: "./static/template.html"
theme: "./static/theme.css"
confirm-mail: "./static/confirm.md"

redirects:
Expand Down
115 changes: 115 additions & 0 deletions docs/TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Template Variables Reference

5000mails uses [Go templates](https://pkg.go.dev/text/template) in two places during the render pipeline:

1. **Markdown content** — the raw `.md` source (confirm-mail template or newsletter body) is executed as a Go template before Markdown parsing.
2. **HTML layout template** — the `template.html` wrapper is executed after Markdown-to-HTML conversion.

Both stages share the same data map, which is populated automatically with the variables below and merged with any custom `data` fields supplied via the API.

---

## Automatic variables

The table shows which variables are automatically injected in each sending context. Custom variables passed via `data` are always available on top of these.

| Variable | Type | Confirm mail | Mail to list | Test mail | Description |
| ---------------------------- | ------------- | :----------: | :----------: | :-------: | ---------------------------------------------------------------------------- |
| `Recipient` | `domain.User` | ✓ | ✓ | ✓ | The recipient of this mail (see fields below) |
| `Recipient.ID` | `uint` | ✓ | ✓ | ✓ | Database ID of the subscriber |
| `Recipient.Name` | `string` | ✓ | ✓ | ✓ | Display name |
| `Recipient.Email` | `string` | ✓ | ✓ | ✓ | Email address |
| `Recipient.MailingListName` | `string` | ✓ | ✓ | ✓ | Name of the mailing list the subscriber belongs to |
| `Recipient.UnsubscribeToken` | `string` | ✓ | ✓ | ✓ | Opaque token used to build unsubscribe links |
| `Recipient.ConfirmedAt` | `*time.Time` | ✗¹ | ✓ | ✗ | Timestamp of double opt-in confirmation (`nil` if unconfirmed) |
| `confirmURL` | `string` | ✓ | ✗ | ✗ | Full URL the subscriber must visit to confirm (`baseURL/confirm/<token>`) |
| `token` | `string` | ✓ | ✗ | ✗ | Raw confirmation token (same value as the last path segment of `confirmURL`) |
| `unsubscribeURL` | `string` | ✗ | ✓ | ✗ | Full URL to unsubscribe (`baseURL/unsubscribe/<UnsubscribeToken>`) |

> ¹ Always `nil` in the confirmation mail — the user has not confirmed yet.

---

## HTML layout template variables

In addition to all variables above (and any custom `data`), the following keys are injected exclusively when the HTML layout template (`template.html`) is executed:

| Variable | Type | Description |
| --------------------- | --------------------- | ----------------------------------------------------------------------------------------------- |
| `html` | `string` | Rendered HTML produced from the Markdown body |
| `metadata` | `domain.MailMetadata` | Typed, parsed frontmatter (see fields below) |
| `metadata.Subject` | `string` | Email subject from the `subject` frontmatter field |
| `metadata.SenderName` | `string` | Sender display name from the `sender` frontmatter field |
| `frontmatter` | `map[string]any` | Raw key-value map of **all** frontmatter fields, including any custom ones (e.g. `{{.frontmatter.myField}}`) |

---

## Frontmatter

Every markdown template (confirm-mail and newsletter bodies) can include a YAML frontmatter block at the top. The renderer strips and parses it before Markdown processing — it is not rendered into the email body.

The known fields (`subject`, `sender`) are mapped into the typed `metadata` object. **All fields**, including any custom ones, are also available as a raw map under `frontmatter` in the HTML layout template.

```markdown
---
subject: "Your subject line"
sender: "Your Newsletter Name"
---

Body starts here…
```

| Field | Description |
|--------------|-------------------------------------------------------------------------------------------|
| `subject` | Email subject line |
| `sender` | Sender display name shown by mail clients |
| *(any key)* | Custom fields — accessible in the HTML layout template via `{{.frontmatter.yourField}}` |

---

## Examples

### Confirm mail

```markdown
---
subject: "Please confirm your subscription"
sender: "My Newsletter"
---

Hi {{.Recipient.Name}},

Click below to confirm your subscription:

[Confirm my subscription]({{.confirmURL}})
```

### Newsletter body

```markdown
---
subject: "Issue #42"
sender: "My Newsletter"
---

Hello {{.Recipient.Name}},

Welcome to this week's edition…

[Unsubscribe]({{.unsubscribeURL}})
```

### HTML layout (`template.html`)

```html
<html>
<head>
<title>{{.metadata.Subject}}</title>
<!-- custom frontmatter field -->
<meta name="description" content="{{.frontmatter.description}}">
</head>
<body>
{{.html}}
</body>
</html>
```
9 changes: 2 additions & 7 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,7 @@ func main() {
logger.Error("loading template", slog.String("path", cfg.Paths.Template), slog.Any("error", err))
os.Exit(1)
}
themeBytes, err := config.FetchResource(cfg.Paths.Theme)
if err != nil {
logger.Error("loading theme", slog.String("path", cfg.Paths.Theme), slog.Any("error", err))
os.Exit(1)
}
rndr, err := renderer.NewGoldmarkRenderer(tmplBytes, themeBytes, logger)
rndr, err := renderer.NewGoldmarkRenderer(tmplBytes, logger)
if err != nil {
logger.Error("creating renderer", slog.Any("error", err))
os.Exit(1)
Expand All @@ -72,7 +67,7 @@ func main() {

subscriptionSvc := service.NewSubscriptionService(repo, repo, repo, rndr, sender, string(confirmRaw), cfg.BaseURL)
listSvc := service.NewListService(repo, repo)
mailSvc := service.NewMailService(repo, repo, rndr, sender)
mailSvc := service.NewMailService(repo, repo, rndr, sender, cfg.BaseURL)

publicHandler := api.NewPublicHandler(subscriptionSvc, api.RedirectPages{
SubscribeSuccess: cfg.Redirects.SubscribeSuccess,
Expand Down
27 changes: 15 additions & 12 deletions renderer/goldmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,18 @@ import (
// GoldmarkRenderer implements domain.Renderer using Go templates and Goldmark.
type GoldmarkRenderer struct {
tmpl *template.Template
theme string
logger *slog.Logger
md goldmark.Markdown
}

// NewGoldmarkRenderer parses tmpl as a Go HTML template and returns a renderer.
func NewGoldmarkRenderer(tmpl, theme []byte, logger *slog.Logger) (*GoldmarkRenderer, error) {
func NewGoldmarkRenderer(tmpl []byte, logger *slog.Logger) (*GoldmarkRenderer, error) {
t, err := template.New("layout").Parse(string(tmpl))
if err != nil {
return nil, fmt.Errorf("parsing renderer layout template: %w", err)
}
return &GoldmarkRenderer{
tmpl: t,
theme: string(theme),
logger: logger,
md: goldmark.New(),
}, nil
Expand All @@ -47,7 +45,7 @@ func (r *GoldmarkRenderer) Render(raw *string, data map[string]any) (domain.Mail
return domain.MailMetadata{}, "", fmt.Errorf("templating markdown content: %w", err)
}

metadata, markdownBody, err := parseFrontmatter(templated)
metadata, rawFM, markdownBody, err := parseFrontmatter(templated)
if err != nil {
return domain.MailMetadata{}, "", fmt.Errorf("parsing frontmatter: %w", err)
}
Expand All @@ -58,9 +56,9 @@ func (r *GoldmarkRenderer) Render(raw *string, data map[string]any) (domain.Mail
}

layoutData := mergeData(data, map[string]any{
"html": htmlBuf.String(),
"metadata": metadata,
"theme": r.theme,
"html": htmlBuf.String(),
"metadata": metadata,
"frontmatter": rawFM,
})

var finalBuf bytes.Buffer
Expand Down Expand Up @@ -89,10 +87,10 @@ type frontmatterFields struct {
Sender string `yaml:"sender"`
}

func parseFrontmatter(s string) (domain.MailMetadata, string, error) {
func parseFrontmatter(s string) (domain.MailMetadata, map[string]any, string, error) {
const marker = "---"
if !strings.HasPrefix(s, marker) {
return domain.MailMetadata{}, s, nil
return domain.MailMetadata{}, nil, s, nil
}

after := strings.TrimPrefix(s, marker)
Expand All @@ -101,7 +99,7 @@ func parseFrontmatter(s string) (domain.MailMetadata, string, error) {

end := strings.Index(after, "\n---")
if end == -1 {
return domain.MailMetadata{}, "", fmt.Errorf("frontmatter opening marker has no closing marker")
return domain.MailMetadata{}, nil, "", fmt.Errorf("frontmatter opening marker has no closing marker")
}

yamlSrc := after[:end]
Expand All @@ -111,10 +109,15 @@ func parseFrontmatter(s string) (domain.MailMetadata, string, error) {

var fm frontmatterFields
if err := yaml.Unmarshal([]byte(yamlSrc), &fm); err != nil {
return domain.MailMetadata{}, "", fmt.Errorf("parsing frontmatter yaml: %w", err)
return domain.MailMetadata{}, nil, "", fmt.Errorf("parsing frontmatter yaml: %w", err)
}

return domain.MailMetadata{Subject: fm.Subject, SenderName: fm.Sender}, body, nil
var rawFM map[string]any
if err := yaml.Unmarshal([]byte(yamlSrc), &rawFM); err != nil {
return domain.MailMetadata{}, nil, "", fmt.Errorf("parsing frontmatter yaml: %w", err)
}

return domain.MailMetadata{Subject: fm.Subject, SenderName: fm.Sender}, rawFM, body, nil
}

func mergeData(base, extra map[string]any) map[string]any {
Expand Down
35 changes: 22 additions & 13 deletions renderer/goldmark_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const testLayout = `Subject:{{.metadata.Subject}} Sender:{{.metadata.SenderName}

func newRenderer(t *testing.T) *GoldmarkRenderer {
t.Helper()
r, err := NewGoldmarkRenderer([]byte(testLayout), nil, slog.Default())
r, err := NewGoldmarkRenderer([]byte(testLayout), slog.Default())
if err != nil {
t.Fatalf("NewGoldmarkRenderer: %v", err)
}
Expand All @@ -25,7 +25,7 @@ func newRenderer(t *testing.T) *GoldmarkRenderer {

func TestParseFrontmatter_ValidBlock(t *testing.T) {
input := "---\nsubject: \"Hello\"\nsender: \"Bot\"\n---\n# Body"
meta, body, err := parseFrontmatter(input)
meta, rawFM, body, err := parseFrontmatter(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
Expand All @@ -38,33 +38,42 @@ func TestParseFrontmatter_ValidBlock(t *testing.T) {
if !strings.HasPrefix(body, "# Body") {
t.Errorf("unexpected body: %q", body)
}
if rawFM["subject"] != "Hello" {
t.Errorf("expected rawFM[subject] = %q, got %v", "Hello", rawFM["subject"])
}
if rawFM["sender"] != "Bot" {
t.Errorf("expected rawFM[sender] = %q, got %v", "Bot", rawFM["sender"])
}
}

func TestParseFrontmatter_NoFrontmatter(t *testing.T) {
input := "# Just markdown"
meta, body, err := parseFrontmatter(input)
meta, rawFM, body, err := parseFrontmatter(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if meta != (domain.MailMetadata{}) {
t.Errorf("expected empty metadata, got %+v", meta)
}
if rawFM != nil {
t.Errorf("expected nil rawFM for no frontmatter, got %v", rawFM)
}
if body != input {
t.Errorf("expected body to equal input, got %q", body)
}
}

func TestParseFrontmatter_UnclosedMarkerErrors(t *testing.T) {
input := "---\nsubject: oops\n"
_, _, err := parseFrontmatter(input)
_, _, _, err := parseFrontmatter(input)
if err == nil {
t.Fatal("expected error for unclosed frontmatter, got nil")
}
}

func TestParseFrontmatter_InvalidYAMLErrors(t *testing.T) {
input := "---\n: bad: yaml: [\n---\n# body"
_, _, err := parseFrontmatter(input)
_, _, _, err := parseFrontmatter(input)
if err == nil {
t.Fatal("expected error for invalid YAML, got nil")
}
Expand Down Expand Up @@ -137,31 +146,31 @@ func TestRender_InvalidContentTemplateErrors(t *testing.T) {
}

func TestRender_InvalidLayoutTemplateErrors(t *testing.T) {
_, err := NewGoldmarkRenderer([]byte("{{.unclosed"), nil, slog.Default())
_, err := NewGoldmarkRenderer([]byte("{{.unclosed"), slog.Default())
if err == nil {
t.Fatal("expected error for invalid layout template, got nil")
}
}

func TestRender_ThemeInjectedIntoLayout(t *testing.T) {
layout := `<style>{{.theme}}</style>{{.html}}`
r, err := NewGoldmarkRenderer([]byte(layout), []byte("body{color:red}"), slog.Default())
func TestRender_FrontmatterInjectedIntoLayout(t *testing.T) {
layout := `{{.frontmatter.subject}} / {{.frontmatter.custom}}: {{.html}}`
r, err := NewGoldmarkRenderer([]byte(layout), slog.Default())
if err != nil {
t.Fatalf("NewGoldmarkRenderer: %v", err)
}
raw := "---\nsubject: S\nsender: B\n---\nhi"
raw := "---\nsubject: Weekly\nsender: Bot\ncustom: extra\n---\nhi"
_, body, err := r.Render(&raw, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(body, "<style>body{color:red}</style>") {
t.Errorf("expected theme in layout output, got:\n%s", body)
if !strings.HasPrefix(body, "Weekly / extra:") {
t.Errorf("expected frontmatter fields in layout output, got:\n%s", body)
}
}

func TestRender_ExtraDataPassedToLayout(t *testing.T) {
layout := `{{.customKey}}: {{.html}}`
r, err := NewGoldmarkRenderer([]byte(layout), nil, slog.Default())
r, err := NewGoldmarkRenderer([]byte(layout), slog.Default())
if err != nil {
t.Fatalf("NewGoldmarkRenderer: %v", err)
}
Expand Down
Loading
Loading