Skip to content
quarry Artifact [d59c830537]
Login

Artifact [d59c830537]

Artifact d59c830537b7e12dade90704fc17d168245fffd04f6f76312849336dc919ca4c:


// email.go turns rendered markdown into HTML a mail client will show:
// styles inline, no remote images, every link absolute.
package markup

import (
	"bytes"
	"html/template"
	"strings"

	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// EmailHTMLMax caps the HTML part of a message; past it the caller sends
// the text alone.
const EmailHTMLMax = 200 << 10

// The card's palette, inline for clients that strip <style>; the dark
// counterparts live in the document's prefers-color-scheme block, keyed
// by the q- classes set beside each style.
const (
	mailRule = "#d5c4a1"
	mailWash = "#efe6cf"
	mailMono = "'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace"
	mailCell = "padding:7px 12px;"
	mailHead = "background:#ece2c9;font-weight:600;border-bottom:1px solid " + mailRule + ";"
)

var mailStyles = map[string]string{
	"p":          "margin:0 0 12px;",
	"a":          "color:#a04412;",
	"blockquote": "border-left:3px solid " + mailRule + ";padding:2px 0 2px 12px;margin:0 0 14px;color:#665c54;",
	"pre":        "margin:12px 0;padding:10px 12px;background:" + mailWash + ";border-radius:6px;overflow-x:auto;font-family:" + mailMono + ";font-size:13px;line-height:1.4;",
	"code":       "font-family:" + mailMono + ";font-size:13px;background:" + mailWash + ";border-radius:4px;padding:1px 5px;",
	"table":      "border:1px solid " + mailRule + ";border-radius:6px;border-collapse:separate;border-spacing:0;margin:4px 0 14px;font-size:14px;",
	"th":         mailCell + mailHead + "text-align:left;",
	"td":         mailCell,
	"li":         "margin:0 0 4px;",
	"ul":         "margin:0 0 14px;padding-left:22px;",
	"ol":         "margin:0 0 14px;padding-left:22px;",
	"h1":         "margin:16px 0 8px;font-size:1.5em;",
	"h2":         "margin:16px 0 8px;font-size:1.3em;",
	"h3":         "margin:14px 0 6px;font-size:1.15em;",
	"h4":         "margin:12px 0 6px;font-size:1em;",
	"h5":         "margin:12px 0 6px;font-size:0.95em;",
	"h6":         "margin:12px 0 6px;font-size:0.9em;",
	"hr":         "border:0;border-top:1px solid " + mailRule + ";margin:16px 0;",
}

// mailClasses are the dark-mode hooks, by element.
var mailClasses = map[string]string{
	"a": "q-a", "code": "q-code", "pre": "q-code", "blockquote": "q-quote",
	"table": "q-tbl", "th": "q-th", "hr": "q-rule",
}

// mailAlerts is each callout's rule colour, keyed by the kind in its class.
var mailAlerts = map[string]string{
	"note": "#4a8ad4", "tip": "#4caf7a", "important": "#9b7ad6",
	"warning": "#d6a13a", "caution": "#d65a5a",
}

// EmailHTML renders src (markdown) with the pages' own renderer and
// sanitizer, then makes the result mail-safe: inline styles, images
// replaced by links to their source, hrefs made absolute through link.
// It returns the body only; EmailDocument wraps it in the card. ok is
// false when rendering fails or the result exceeds EmailHTMLMax; the
// caller then sends text only.
func EmailHTML(src []byte, r Resolver, link func(path string) string) (template.HTML, bool) {
	rendered, err := Render(src, r)
	if err != nil {
		return "", false
	}
	nodes, err := html.ParseFragment(bytes.NewReader(rendered), &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div})
	if err != nil {
		return "", false
	}
	root := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
	for _, n := range nodes {
		root.AppendChild(n)
	}
	mailSafe(root, link)

	var buf bytes.Buffer
	for c := root.FirstChild; c != nil; c = c.NextSibling {
		if err := html.Render(&buf, c); err != nil {
			return "", false
		}
	}
	if buf.Len() > EmailHTMLMax {
		return "", false
	}
	return template.HTML(buf.String()), true
}

func mailSafe(n *html.Node, link func(string) string) {
	for c := n.FirstChild; c != nil; {
		next := c.NextSibling
		if c.Type == html.ElementNode {
			switch c.DataAtom {
			case atom.Svg, atom.Script, atom.Style:
				n.RemoveChild(c)
				c = next
				continue
			case atom.Img:
				a := imgAsLink(c, link)
				n.InsertBefore(a, c)
				n.RemoveChild(c)
				c = next
				continue
			}
			mailSafe(c, link)
			mailStyle(c, link)
			if c.DataAtom == atom.Table {
				mailTable(c)
			}
		}
		c = next
	}
}

// inBox reports whether n is a quote or callout, whose last paragraph
// should not add its own margin to the box's padding.
func inBox(n *html.Node) bool {
	class, _ := nodeAttr(n, "class")
	return n.DataAtom == atom.Blockquote || (n.DataAtom == atom.Div && strings.HasPrefix(class, "md-alert"))
}

// setClass replaces n's class with the dark-mode hook; the source
// classes were only ever styling hooks for the pages.
func setClass(n *html.Node, class string) {
	for i, a := range n.Attr {
		if a.Key == "class" {
			n.Attr[i].Val = class
			return
		}
	}
	n.Attr = append(n.Attr, html.Attribute{Key: "class", Val: class})
}

func addStyle(n *html.Node, css string) {
	for i, a := range n.Attr {
		if a.Key == "style" {
			n.Attr[i].Val += css
			return
		}
	}
	n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: css})
}

// mailTable finishes a styled table: the header's rule, alternate rows
// washed, and the outer corners rounded on the cells (a client will not
// clip to the table's own radius).
func mailTable(t *html.Node) {
	var rows []*html.Node
	var walk func(*html.Node)
	walk = func(n *html.Node) {
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			if c.DataAtom == atom.Tr {
				rows = append(rows, c)
			} else {
				walk(c)
			}
		}
	}
	walk(t)
	cells := func(tr *html.Node) (out []*html.Node) {
		for c := tr.FirstChild; c != nil; c = c.NextSibling {
			if c.DataAtom == atom.Th || c.DataAtom == atom.Td {
				out = append(out, c)
			}
		}
		return out
	}
	data := 0
	for i, tr := range rows {
		cs := cells(tr)
		if len(cs) == 0 {
			continue
		}
		head := cs[0].DataAtom == atom.Th
		if !head {
			data++
			if data%2 == 0 {
				setClass(tr, "q-tde")
				addStyle(tr, "background:#f7f0df;")
				for _, c := range cs {
					setClass(c, "q-tde")
					addStyle(c, "background:#f7f0df;")
				}
			}
		}
		if i == 0 {
			addStyle(cs[0], "border-top-left-radius:5px;")
			addStyle(cs[len(cs)-1], "border-top-right-radius:5px;")
		}
		if i == len(rows)-1 {
			addStyle(cs[0], "border-bottom-left-radius:5px;")
			addStyle(cs[len(cs)-1], "border-bottom-right-radius:5px;")
			if head {
				for _, c := range cs {
					addStyle(c, "border-bottom:0;")
				}
			}
		}
	}
}

func nodeAttr(n *html.Node, key string) (string, bool) {
	for _, a := range n.Attr {
		if a.Key == key {
			return a.Val, true
		}
	}
	return "", false
}

// absolute makes an href absolute; "" means it cannot be, and is dropped.
func absolute(href string, link func(string) string) string {
	switch {
	case strings.HasPrefix(href, "//"):
		return ""
	case strings.HasPrefix(href, "/"):
		return link(href)
	case strings.HasPrefix(href, "http://"), strings.HasPrefix(href, "https://"), strings.HasPrefix(href, "mailto:"):
		return href
	}
	return ""
}

// imgAsLink stands a link to the image's source in for the image: a
// remote image in a message is a read receipt.
func imgAsLink(img *html.Node, link func(string) string) *html.Node {
	src, _ := nodeAttr(img, "src")
	alt, _ := nodeAttr(img, "alt")
	if alt == "" {
		alt = src
	}
	a := &html.Node{Type: html.ElementNode, Data: "a", DataAtom: atom.A}
	if href := absolute(src, link); href != "" {
		a.Attr = []html.Attribute{{Key: "href", Val: href}, {Key: "class", Val: "q-a"}, {Key: "style", Val: mailStyles["a"]}}
	}
	a.AppendChild(&html.Node{Type: html.TextNode, Data: alt})
	return a
}

// mailStyle rewrites one element's attributes: class becomes inline style
// and links become absolute.
func mailStyle(n *html.Node, link func(string) string) {
	class, _ := nodeAttr(n, "class")
	style := mailStyles[n.Data]
	hook := mailClasses[n.Data]
	switch {
	case n.DataAtom == atom.Div && strings.HasPrefix(class, "md-alert"):
		kind := strings.TrimPrefix(class, "md-alert md-alert-")
		style = "margin:0 0 14px;padding:6px 12px;background:#f7f0df;border-left:3px solid " + mailAlerts[kind] + ";"
		hook = "q-tde"
	case n.DataAtom == atom.Code && n.Parent != nil && n.Parent.DataAtom == atom.Pre:
		style = "font-family:inherit;font-size:inherit;background:none;padding:0;"
		hook = ""
	case n.DataAtom == atom.P && class == "md-alert-title":
		style = "margin:0 0 6px;font-weight:600;"
	case (n.DataAtom == atom.Th || n.DataAtom == atom.Td) && strings.Contains(class, "md-num"):
		style += "text-align:right;"
	case n.DataAtom == atom.P && n.NextSibling == nil && n.Parent != nil && inBox(n.Parent):
		style = "margin:0;"
	}
	keep := n.Attr[:0]
	for _, a := range n.Attr {
		switch a.Key {
		case "href":
			if h := absolute(a.Val, link); h != "" {
				a.Val = h
				keep = append(keep, a)
			}
		case "align", "colspan", "rowspan", "title":
			keep = append(keep, a)
		}
	}
	n.Attr = keep
	if hook != "" {
		setClass(n, hook)
	}
	if style != "" {
		n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: style})
	}
}