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/.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 }