- Parse Winter INI + == Markdown templates and Goldmark HTML - Memory driver stores rendered subject, text, and HTML - Caller selects -en siblings by full dotted name Co-authored-by: Cursor <cursoragent@cursor.com>
435 lines
11 KiB
Go
435 lines
11 KiB
Go
package postcard
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
texttemplate "text/template"
|
|
|
|
"github.com/yuin/goldmark"
|
|
)
|
|
|
|
const (
|
|
defaultLayoutAlias = "default"
|
|
defaultLayoutName = "postcard::mail.layouts.default"
|
|
mailPrefix = "mail."
|
|
assetRoot = "views/mail/"
|
|
)
|
|
|
|
var (
|
|
dangerousScheme = regexp.MustCompile(`(?i)(?:javascript|vbscript|data):`)
|
|
eventHandler = regexp.MustCompile(`(?i)\son[a-z]+\s*=`)
|
|
rawUnsafeTag = regexp.MustCompile(`(?i)<(?:script|iframe|object|embed)\b`)
|
|
|
|
markdown = goldmark.New()
|
|
)
|
|
|
|
//go:embed assets/default.htm
|
|
var defaultLayoutSource string
|
|
|
|
// Catalog holds registered mail templates and layouts.
|
|
type Catalog struct {
|
|
mu sync.RWMutex
|
|
templates map[string]*parsedTemplate
|
|
layouts map[string]*parsedLayout
|
|
aliases map[string]map[string]string // pluginID -> short name -> full name
|
|
}
|
|
|
|
type parsedTemplate struct {
|
|
name string
|
|
pluginID string
|
|
subject string
|
|
description string
|
|
layout string // short name from header; empty means default
|
|
body string
|
|
}
|
|
|
|
type parsedLayout struct {
|
|
name string
|
|
pluginID string
|
|
headerName string
|
|
textWrap string
|
|
htmlWrap string
|
|
}
|
|
|
|
// NewCatalog returns an empty catalog that already contains postcard's
|
|
// neutral default layout.
|
|
func NewCatalog() *Catalog {
|
|
c := &Catalog{
|
|
templates: make(map[string]*parsedTemplate),
|
|
layouts: make(map[string]*parsedLayout),
|
|
aliases: make(map[string]map[string]string),
|
|
}
|
|
layout, err := parseLayout(defaultLayoutName, "postcard", defaultLayoutSource)
|
|
if err != nil {
|
|
panic("postcard: default layout: " + err.Error())
|
|
}
|
|
c.layouts[defaultLayoutName] = layout
|
|
return c
|
|
}
|
|
|
|
// Register loads declared dotted template and layout names from fsys.
|
|
// Each name must be owned by pluginID and map to views/mail/<name>.htm.
|
|
func (c *Catalog) Register(pluginID string, fsys fs.FS, templates []string, layouts map[string]string) error {
|
|
if c == nil {
|
|
return fmt.Errorf("postcard: catalog is nil")
|
|
}
|
|
if pluginID == "" {
|
|
return fmt.Errorf("postcard: plugin id is empty")
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
alias := make(map[string]string, len(layouts))
|
|
for short, full := range layouts {
|
|
if short == "" || full == "" {
|
|
return fmt.Errorf("postcard: empty layout alias for %s", pluginID)
|
|
}
|
|
if short == defaultLayoutAlias {
|
|
return fmt.Errorf("postcard: layout alias %q is reserved", short)
|
|
}
|
|
if err := assertOwner(pluginID, full); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := c.layouts[full]; exists {
|
|
return fmt.Errorf("postcard: duplicate layout %s", full)
|
|
}
|
|
path, err := assetPath(full)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := fs.ReadFile(fsys, path)
|
|
if err != nil {
|
|
return fmt.Errorf("postcard: missing layout %s", full)
|
|
}
|
|
parsed, err := parseLayout(full, pluginID, string(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.layouts[full] = parsed
|
|
alias[short] = full
|
|
}
|
|
if len(alias) > 0 {
|
|
if c.aliases[pluginID] == nil {
|
|
c.aliases[pluginID] = make(map[string]string, len(alias))
|
|
}
|
|
for short, full := range alias {
|
|
if _, exists := c.aliases[pluginID][short]; exists {
|
|
return fmt.Errorf("postcard: duplicate layout alias %s for %s", short, pluginID)
|
|
}
|
|
c.aliases[pluginID][short] = full
|
|
}
|
|
}
|
|
|
|
for _, name := range templates {
|
|
if err := assertOwner(pluginID, name); err != nil {
|
|
return err
|
|
}
|
|
if _, exists := c.templates[name]; exists {
|
|
return fmt.Errorf("postcard: duplicate template %s", name)
|
|
}
|
|
path, err := assetPath(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := fs.ReadFile(fsys, path)
|
|
if err != nil {
|
|
return fmt.Errorf("postcard: missing template %s", name)
|
|
}
|
|
parsed, err := parseTemplateFile(name, pluginID, string(raw))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := c.resolveLayoutLocked(parsed); err != nil {
|
|
return err
|
|
}
|
|
c.templates[name] = parsed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Catalog) resolveLayoutLocked(t *parsedTemplate) error {
|
|
short := strings.TrimSpace(t.layout)
|
|
if short == "" || short == defaultLayoutAlias {
|
|
t.layout = defaultLayoutAlias
|
|
return nil
|
|
}
|
|
full, ok := c.aliases[t.pluginID][short]
|
|
if !ok {
|
|
return fmt.Errorf("postcard: unknown layout %s", short)
|
|
}
|
|
if _, ok := c.layouts[full]; !ok {
|
|
return fmt.Errorf("postcard: missing layout %s", full)
|
|
}
|
|
t.layout = full
|
|
return nil
|
|
}
|
|
|
|
func (c *Catalog) lookupTemplate(name string) (*parsedTemplate, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
t, ok := c.templates[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("postcard: unknown template %s", name)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func (c *Catalog) lookupLayout(name string) (*parsedLayout, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if name == "" || name == defaultLayoutAlias {
|
|
name = defaultLayoutName
|
|
}
|
|
l, ok := c.layouts[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("postcard: missing layout %s", name)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
func assertOwner(pluginID, fullName string) error {
|
|
prefix := pluginID + "::"
|
|
if !strings.HasPrefix(fullName, prefix) {
|
|
return fmt.Errorf("postcard: template %s is not owned by %s", fullName, pluginID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assetPath(fullName string) (string, error) {
|
|
_, rest, ok := strings.Cut(fullName, "::")
|
|
if !ok {
|
|
return "", fmt.Errorf("postcard: invalid mail name %s", fullName)
|
|
}
|
|
after, ok := strings.CutPrefix(rest, mailPrefix)
|
|
if !ok || after == "" {
|
|
return "", fmt.Errorf("postcard: invalid mail name %s", fullName)
|
|
}
|
|
if strings.Contains(after, "..") || strings.ContainsAny(after, `/\`) {
|
|
return "", fmt.Errorf("postcard: invalid mail name %s", fullName)
|
|
}
|
|
return assetRoot + strings.ReplaceAll(after, ".", "/") + ".htm", nil
|
|
}
|
|
|
|
func parseTemplateFile(name, pluginID, src string) (*parsedTemplate, error) {
|
|
header, body, err := splitTwo(src)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
|
|
}
|
|
fields, err := parseINI(header)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
|
|
}
|
|
return &parsedTemplate{
|
|
name: name,
|
|
pluginID: pluginID,
|
|
subject: fields["subject"],
|
|
description: fields["description"],
|
|
layout: fields["layout"],
|
|
body: strings.TrimSuffix(body, "\n"),
|
|
}, nil
|
|
}
|
|
|
|
func parseLayout(name, pluginID, src string) (*parsedLayout, error) {
|
|
header, textWrap, htmlWrap, err := splitThree(src)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
|
|
}
|
|
fields, err := parseINI(header)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("postcard: parse %s: %w", name, err)
|
|
}
|
|
return &parsedLayout{
|
|
name: name,
|
|
pluginID: pluginID,
|
|
headerName: fields["name"],
|
|
textWrap: strings.TrimSuffix(textWrap, "\n"),
|
|
htmlWrap: strings.TrimSuffix(htmlWrap, "\n"),
|
|
}, nil
|
|
}
|
|
|
|
func splitTwo(src string) (header, body string, err error) {
|
|
parts, err := splitEQ(src, 2)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
func splitThree(src string) (header, textWrap, htmlWrap string, err error) {
|
|
parts, err := splitEQ(src, 3)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
return parts[0], parts[1], parts[2], nil
|
|
}
|
|
|
|
func splitEQ(src string, n int) ([]string, error) {
|
|
src = strings.ReplaceAll(src, "\r\n", "\n")
|
|
lines := strings.Split(src, "\n")
|
|
parts := make([]string, 0, n)
|
|
start := 0
|
|
for i, line := range lines {
|
|
if strings.TrimSpace(line) != "==" {
|
|
continue
|
|
}
|
|
parts = append(parts, strings.Join(lines[start:i], "\n"))
|
|
start = i + 1
|
|
if len(parts) == n-1 {
|
|
parts = append(parts, strings.Join(lines[start:], "\n"))
|
|
return parts, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("missing == separator")
|
|
}
|
|
|
|
func parseINI(header string) (map[string]string, error) {
|
|
out := make(map[string]string)
|
|
for _, line := range strings.Split(header, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
key, val, ok := strings.Cut(line, "=")
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid header line %q", line)
|
|
}
|
|
key = strings.ToLower(strings.TrimSpace(key))
|
|
val = strings.TrimSpace(val)
|
|
if len(val) >= 2 {
|
|
if q := val[0]; (q == '"' || q == '\'') && val[len(val)-1] == q {
|
|
val = val[1 : len(val)-1]
|
|
}
|
|
}
|
|
if key == "" {
|
|
return nil, fmt.Errorf("invalid header line %q", line)
|
|
}
|
|
out[key] = val
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type renderOptions struct {
|
|
css string
|
|
brandCSS string
|
|
}
|
|
|
|
func (c *Catalog) render(msg Message, opts renderOptions) (RenderedMessage, error) {
|
|
var out RenderedMessage
|
|
t, err := c.lookupTemplate(msg.Template)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
vars := msg.Vars
|
|
if vars == nil {
|
|
vars = map[string]any{}
|
|
}
|
|
subjectSrc := t.subject
|
|
if strings.TrimSpace(msg.Subject) != "" {
|
|
subjectSrc = msg.Subject
|
|
}
|
|
subject, err := execHTML(t.name+".subject", subjectSrc, vars)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if strings.ContainsAny(subject, "\r\n") {
|
|
return out, fmt.Errorf("postcard: subject contains CR/LF")
|
|
}
|
|
markdownBody, err := execHTML(t.name+".body", t.body, vars)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
htmlBody, err := markdownHTML(markdownBody)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if err := validateHTML(htmlBody); err != nil {
|
|
return out, err
|
|
}
|
|
layout, err := c.lookupLayout(t.layout)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
text, err := execText(layout.name+".text", layout.textWrap, map[string]any{
|
|
"Content": markdownBody,
|
|
"Subject": subject,
|
|
"css": opts.css,
|
|
"brandCss": opts.brandCSS,
|
|
})
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
html, err := execHTML(layout.name+".html", layout.htmlWrap, map[string]any{
|
|
"Content": template.HTML(htmlBody),
|
|
"Subject": subject,
|
|
"css": template.CSS(opts.css),
|
|
"brandCss": template.CSS(opts.brandCSS),
|
|
})
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if err := validateHTML(html); err != nil {
|
|
return out, err
|
|
}
|
|
out = RenderedMessage{
|
|
To: append([]string(nil), msg.To...),
|
|
Cc: append([]string(nil), msg.Cc...),
|
|
Bcc: append([]string(nil), msg.Bcc...),
|
|
ReplyTo: msg.ReplyTo,
|
|
Subject: subject,
|
|
HTML: html,
|
|
Text: text,
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func markdownHTML(src string) (string, error) {
|
|
var buf bytes.Buffer
|
|
if err := markdown.Convert([]byte(src), &buf); err != nil {
|
|
return "", fmt.Errorf("postcard: markdown: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func validateHTML(html string) error {
|
|
if rawUnsafeTag.MatchString(html) {
|
|
return fmt.Errorf("postcard: rendered HTML contains raw unsafe tags")
|
|
}
|
|
if eventHandler.MatchString(html) {
|
|
return fmt.Errorf("postcard: rendered HTML contains event handlers")
|
|
}
|
|
if dangerousScheme.MatchString(html) {
|
|
return fmt.Errorf("postcard: rendered HTML contains a dangerous URL scheme")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func execHTML(name, src string, data any) (string, error) {
|
|
tmpl, err := template.New(name).Option("missingkey=zero").Parse(src)
|
|
if err != nil {
|
|
return "", fmt.Errorf("postcard: parse %s: %w", name, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return "", fmt.Errorf("postcard: execute %s: %w", name, err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func execText(name, src string, data any) (string, error) {
|
|
tmpl, err := texttemplate.New(name).Option("missingkey=zero").Parse(src)
|
|
if err != nil {
|
|
return "", fmt.Errorf("postcard: parse %s: %w", name, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.Execute(&buf, data); err != nil {
|
|
return "", fmt.Errorf("postcard: execute %s: %w", name, err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|