diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3709ef2..bc4404e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -79,5 +78,3 @@ jobs: tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ inputs.version }} ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - - diff --git a/config.example.yml b/config.example.yml index 16479f6..1779d8c 100644 --- a/config.example.yml +++ b/config.example.yml @@ -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: diff --git a/config/config.go b/config/config.go index f30d590..58eac22 100644 --- a/config/config.go +++ b/config/config.go @@ -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"` } diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 1c59dd2..140d910 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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. --- @@ -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. --- @@ -125,7 +118,6 @@ auth: paths: template: "./static/template.html" - theme: "./static/theme.css" confirm-mail: "./static/confirm.md" redirects: diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md new file mode 100644 index 0000000..c88d2da --- /dev/null +++ b/docs/TEMPLATE.md @@ -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` | `string` | ✓ | ✗ | ✗ | Raw confirmation token (same value as the last path segment of `confirmURL`) | +| `unsubscribeURL` | `string` | ✗ | ✓ | ✗ | Full URL to unsubscribe (`baseURL/unsubscribe/`) | + +> ¹ 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 + + + {{.metadata.Subject}} + + + + + {{.html}} + + +``` diff --git a/main.go b/main.go index de23ad1..f3ff21d 100644 --- a/main.go +++ b/main.go @@ -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) @@ -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, diff --git a/renderer/goldmark.go b/renderer/goldmark.go index 1765dd7..3cbb362 100644 --- a/renderer/goldmark.go +++ b/renderer/goldmark.go @@ -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 @@ -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) } @@ -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 @@ -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) @@ -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] @@ -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 { diff --git a/renderer/goldmark_test.go b/renderer/goldmark_test.go index 6539da8..7e8b416 100644 --- a/renderer/goldmark_test.go +++ b/renderer/goldmark_test.go @@ -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) } @@ -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) } @@ -38,17 +38,26 @@ 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) } @@ -56,7 +65,7 @@ func TestParseFrontmatter_NoFrontmatter(t *testing.T) { 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") } @@ -64,7 +73,7 @@ func TestParseFrontmatter_UnclosedMarkerErrors(t *testing.T) { 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") } @@ -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 := `{{.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, "") { - 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) } diff --git a/service/mail.go b/service/mail.go index fa9759c..9dfd85c 100644 --- a/service/mail.go +++ b/service/mail.go @@ -14,15 +14,17 @@ type MailService struct { users domain.UserRepository renderer domain.Renderer sender domain.Sender + baseURL string } // NewMailService creates a new MailService. -func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, renderer domain.Renderer, sender domain.Sender) *MailService { +func NewMailService(lists domain.MailingListRepository, users domain.UserRepository, renderer domain.Renderer, sender domain.Sender, baseURL string) *MailService { return &MailService{ lists: lists, users: users, renderer: renderer, sender: sender, + baseURL: baseURL, } } @@ -45,11 +47,12 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin } for _, recipient := range recipients { - recipientData := make(map[string]any, len(data)+1) + recipientData := make(map[string]any, len(data)+2) for k, v := range data { recipientData[k] = v } recipientData["Recipient"] = recipient + recipientData["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken metadata, body, err := s.renderer.Render(&raw, recipientData) if err != nil { @@ -69,11 +72,12 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin // making this suitable for previewing a newsletter before a real dispatch. // data is passed through to the renderer as template variables. func (s *MailService) SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error { - recipientData := make(map[string]any, len(data)+1) + recipientData := make(map[string]any, len(data)+2) for k, v := range data { recipientData[k] = v } recipientData["Recipient"] = recipient + recipientData["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken metadata, body, err := s.renderer.Render(&raw, recipientData) if err != nil { diff --git a/service/mail_test.go b/service/mail_test.go index 806a3ed..bb3909f 100644 --- a/service/mail_test.go +++ b/service/mail_test.go @@ -25,6 +25,7 @@ func TestMailService_SendToList(t *testing.T) { newFakeUserRepo(), &fakeRenderer{metadata: metadata, body: "body"}, sender, + "https://example.com", ) if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -40,7 +41,7 @@ func TestMailService_SendToList(t *testing.T) { confirmedUser(2, "weekly", "bob@example.com"), ) sender := &fakeSender{} - svc := NewMailService(newFakeListRepo(list), users, &fakeRenderer{metadata: metadata, body: "rendered"}, sender) + svc := NewMailService(newFakeListRepo(list), users, &fakeRenderer{metadata: metadata, body: "rendered"}, sender, "https://example.com") if err := svc.SendToList(context.Background(), "weekly", "# Hi", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -66,7 +67,7 @@ func TestMailService_SendToList(t *testing.T) { t.Run("injects Recipient into render data per recipient", func(t *testing.T) { user := confirmedUser(1, "weekly", "alice@example.com") renderer := &fakeRenderer{metadata: metadata, body: "body"} - svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), renderer, &fakeSender{}) + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), renderer, &fakeSender{}, "https://example.com") if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -80,10 +81,25 @@ func TestMailService_SendToList(t *testing.T) { } }) + t.Run("injects unsubscribeURL into render data per recipient", func(t *testing.T) { + user := confirmedUser(1, "weekly", "alice@example.com") + user.UnsubscribeToken = "unsub-tok" + renderer := &fakeRenderer{metadata: metadata, body: "body"} + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), renderer, &fakeSender{}, "https://example.com") + + if err := svc.SendToList(context.Background(), "weekly", "raw", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantURL := "https://example.com/unsubscribe/unsub-tok" + if got, _ := renderer.lastData["unsubscribeURL"].(string); got != wantURL { + t.Errorf("unsubscribeURL = %q, want %q", got, wantURL) + } + }) + t.Run("wraps GetListByName error", func(t *testing.T) { listRepo := newFakeListRepo() listRepo.getByNameErr = errors.New("list missing") - svc := NewMailService(listRepo, newFakeUserRepo(), &fakeRenderer{}, &fakeSender{}) + svc := NewMailService(listRepo, newFakeUserRepo(), &fakeRenderer{}, &fakeSender{}, "https://example.com") err := svc.SendToList(context.Background(), "ghost", "raw", nil) if !errors.Is(err, listRepo.getByNameErr) { t.Errorf("expected wrapped error, got: %v", err) @@ -93,7 +109,7 @@ func TestMailService_SendToList(t *testing.T) { t.Run("wraps GetConfirmedUsers error", func(t *testing.T) { userRepo := newFakeUserRepo() userRepo.getConfirmedErr = errors.New("db down") - svc := NewMailService(newFakeListRepo(list), userRepo, &fakeRenderer{}, &fakeSender{}) + svc := NewMailService(newFakeListRepo(list), userRepo, &fakeRenderer{}, &fakeSender{}, "https://example.com") err := svc.SendToList(context.Background(), "weekly", "raw", nil) if !errors.Is(err, userRepo.getConfirmedErr) { t.Errorf("expected wrapped error, got: %v", err) @@ -107,6 +123,7 @@ func TestMailService_SendToList(t *testing.T) { newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), &fakeRenderer{err: renderErr}, &fakeSender{}, + "https://example.com", ) err := svc.SendToList(context.Background(), "weekly", "raw", nil) if !errors.Is(err, renderErr) { @@ -121,6 +138,7 @@ func TestMailService_SendToList(t *testing.T) { newFakeUserRepo(confirmedUser(1, "weekly", "a@example.com")), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, + "https://example.com", ) err := svc.SendToList(context.Background(), "weekly", "raw", nil) if !errors.Is(err, sendErr) { @@ -135,7 +153,7 @@ func TestMailService_SendTestMail(t *testing.T) { t.Run("renders and sends to given recipient", func(t *testing.T) { sender := &fakeSender{} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), &fakeRenderer{metadata: metadata, body: "preview"}, sender) + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), &fakeRenderer{metadata: metadata, body: "preview"}, sender, "https://example.com") if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -154,7 +172,7 @@ func TestMailService_SendTestMail(t *testing.T) { t.Run("injects Recipient into render data", func(t *testing.T) { renderer := &fakeRenderer{metadata: metadata, body: "body"} - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), renderer, &fakeSender{}) + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), renderer, &fakeSender{}, "https://example.com") if err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil); err != nil { t.Fatalf("unexpected error: %v", err) @@ -168,9 +186,23 @@ func TestMailService_SendTestMail(t *testing.T) { } }) + t.Run("injects unsubscribeURL into render data", func(t *testing.T) { + recipientWithToken := domain.User{ID: 1, Email: "dev@example.com", Name: "Dev", UnsubscribeToken: "my-unsub-tok"} + renderer := &fakeRenderer{metadata: metadata, body: "body"} + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), renderer, &fakeSender{}, "https://example.com") + + if err := svc.SendTestMail(context.Background(), recipientWithToken, "# Draft", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantURL := "https://example.com/unsubscribe/my-unsub-tok" + if got, _ := renderer.lastData["unsubscribeURL"].(string); got != wantURL { + t.Errorf("unsubscribeURL = %q, want %q", got, wantURL) + } + }) + t.Run("wraps renderer error", func(t *testing.T) { renderErr := errors.New("bad template") - svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}) + svc := NewMailService(newFakeListRepo(), newFakeUserRepo(), &fakeRenderer{err: renderErr}, &fakeSender{}, "https://example.com") err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil) if !errors.Is(err, renderErr) { t.Errorf("expected wrapped render error, got: %v", err) @@ -184,6 +216,7 @@ func TestMailService_SendTestMail(t *testing.T) { newFakeUserRepo(), &fakeRenderer{metadata: metadata, body: "body"}, &fakeSender{err: sendErr}, + "https://example.com", ) err := svc.SendTestMail(context.Background(), recipient, "# Draft", nil) if !errors.Is(err, sendErr) { diff --git a/static/template.html b/static/template.html index 46d1436..9934544 100644 --- a/static/template.html +++ b/static/template.html @@ -5,123 +5,107 @@ {{.metadata.Subject}} @@ -129,4 +113,4 @@ {{.html}} - + \ No newline at end of file diff --git a/static/theme.example.css b/static/theme.example.css deleted file mode 100644 index 74757a6..0000000 --- a/static/theme.example.css +++ /dev/null @@ -1,64 +0,0 @@ -/* ── Non-colour tokens: same for both modes ── */ -:root { - /* Layout */ - --content-width: 48rem; - --spacing-page-h: 1rem; - --spacing-page-h-wide: 1.5rem; - --spacing-page-v: 2.5rem; - --gap-base: 0.5rem; - - /* Shape */ - --radius-sm: 3px; - --radius-md: 4px; - --radius-lg: 6px; - - /* Typography */ - --font-body: system-ui, -apple-system, sans-serif; - --font-heading: system-ui, -apple-system, sans-serif; - --font-mono: 'SFMono-Regular', Menlo, Consolas, monospace; - --font-size-base: 1rem; -} - -/* ── Light mode (default) ── */ -:root { - --color-bg: #f8f8f8; - --color-surface: #eeeeee; - --color-border: #cccccc; - --color-text: #2a2a2a; - --color-text-heading: #111111; - --color-text-muted: #666666; - --color-text-dim: #aaaaaa; - --color-header-bg: #1a1a1a; - --color-header-border: #2a2a2a; - --color-header-brand: #f0f0f0; - --color-header-nav: #888888; - --color-accent: #2a6bca; - --color-accent-hover: #1a5ab8; - --color-tag-bg: #ddeeff; - --color-tag-text: #1a4f8f; - --color-code-bg: #efefef; - --color-code-text: #2a2a2a; -} - -/* ── Dark mode ── */ -@media (prefers-color-scheme: dark) { - :root { - --color-bg: #111111; - --color-surface: #1a1a1a; - --color-border: #2a2a2a; - --color-text: #d8d8d8; - --color-text-heading: #f0f0f0; - --color-text-muted: #888888; - --color-text-dim: #555555; - --color-header-bg: #111111; - --color-header-border: #1e1e1e; - --color-header-brand: #f0f0f0; - --color-header-nav: #555555; - --color-accent: #7eb8f7; - --color-accent-hover: #a8d0ff; - --color-tag-bg: #1a1a2e; - --color-tag-text: #7eb8f7; - --color-code-bg: #1e1e2e; - --color-code-text: #c8d3f5; - } -} \ No newline at end of file diff --git a/static/theme.md b/static/theme.md deleted file mode 100644 index 9045348..0000000 --- a/static/theme.md +++ /dev/null @@ -1,50 +0,0 @@ -## Theme variable reference - -### Colors - -| Variable | Purpose | -|---|---| -| `--color-bg` | Main page/body background | -| `--color-surface` | Slightly elevated surface - card backgrounds, banded sections, table header cells | -| `--color-border` | Dividers, rule lines, table and code block outlines | -| `--color-text` | Default body and prose text | -| `--color-text-heading` | Headings and high-emphasis text | -| `--color-text-muted` | Secondary labels - dates, author lines, descriptions | -| `--color-text-dim` | Tertiary / very subtle text - page counters, section separators, tag row labels | -| `--color-header-bg` | Site header bar background | -| `--color-header-border` | Header bottom border | -| `--color-header-brand` | Site name / brand text in the header | -| `--color-header-nav` | Navigation link text in the header | -| `--color-accent` | Primary interactive color - links, blockquote accents, focus rings | -| `--color-accent-hover` | Hover / active state of accent elements | -| `--color-tag-bg` | Tag badge background | -| `--color-tag-text` | Tag badge text | -| `--color-code-bg` | Inline code and code block background | -| `--color-code-text` | Code text | - -### Layout - -| Variable | Purpose | -|---|---| -| `--content-width` | Max width of the main content column (e.g. `52rem`) | -| `--spacing-page-h` | Horizontal page padding at narrow viewports | -| `--spacing-page-h-wide` | Horizontal page padding at wider viewports | -| `--spacing-page-v` | Vertical padding at the top and bottom of the content area | -| `--gap-base` | Minimum meaningful gap between elements. Used directly or as a multiplier (e.g. `calc(var(--gap-base) * 2)`) for spacing between nav items, form controls, list rows, and similar compound layouts | - -### Shape - -| Variable | Purpose | -|---|---| -| `--radius-sm` | Corner radius for small elements - inline code, tag badges, inputs | -| `--radius-md` | Corner radius for mid-size elements - buttons, search box, post cards | -| `--radius-lg` | Corner radius for large elements - code blocks, image frames | - -### Typography - -| Variable | Purpose | -|---|---| -| `--font-body` | Font stack for body text and UI elements | -| `--font-heading` | Font stack for headings (`h1`-`h4`) | -| `--font-mono` | Font stack for code and pre blocks | -| `--font-size-base` | Root font size (cascades via `rem`). Default `1rem` / `16px` | \ No newline at end of file