Skip to content
quarry Artifact [45da4f24f1]
Login

Artifact [45da4f24f1]

Artifact 45da4f24f10dc1e98d7a2b9d8e125e843fff5c8436c84f29c9d506a0d1f3a109:


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

import (
	"bytes"
	gohtml "html"
	"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

// Neutral greys and mid-tone accents, and no text colour at all on prose:
// the client's own foreground and background carry both light and dark.
const (
	mailRule  = "rgba(127,127,127,0.45)"
	mailWash  = "rgba(127,127,127,0.14)"
	mailMono  = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"
	mailLink  = "#4a8ad4"
	mailCell  = "border:1px solid " + mailRule + ";padding:4px 8px;"
	mailAlert = "margin:12px 0;padding:6px 12px;background:" + mailWash + ";border-left:4px solid "
)

var mailStyles = map[string]string{
	"p":          "margin:0 0 12px;",
	"a":          "color:" + mailLink + ";",
	"blockquote": "margin:12px 0;padding:2px 12px;border-left:4px solid " + mailRule + ";background:" + mailWash + ";",
	"pre":        "margin:12px 0;padding:10px 12px;background:" + mailWash + ";border:1px solid " + mailRule + ";border-radius:4px;overflow-x:auto;font-family:" + mailMono + ";font-size:13px;line-height:1.4;",
	"code":       "font-family:" + mailMono + ";font-size:0.92em;background:" + mailWash + ";padding:1px 4px;border-radius:3px;",
	"table":      "border-collapse:collapse;margin:12px 0;",
	"th":         mailCell + "text-align:left;background:" + mailWash + ";",
	"td":         mailCell,
	"li":         "margin:2px 0;",
	"ul":         "margin:0 0 12px;padding-left:24px;",
	"ol":         "margin:0 0 12px;padding-left:24px;",
	"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;",
}

// 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,
// and a closing link to open. 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, open string) (string, 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
	buf.WriteString(`<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;">`)
	for c := root.FirstChild; c != nil; c = c.NextSibling {
		if err := html.Render(&buf, c); err != nil {
			return "", false
		}
	}
	buf.WriteString(`<p style="` + mailStyles["p"] + `"><a href="` + gohtml.EscapeString(open) +
		`" style="` + mailStyles["a"] + `">Open in quarry</a></p></div>`)
	if buf.Len() > EmailHTMLMax {
		return "", false
	}
	return 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)
		}
		c = next
	}
}

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: "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]
	switch {
	case n.DataAtom == atom.Div && strings.HasPrefix(class, "md-alert"):
		kind := strings.TrimPrefix(class, "md-alert md-alert-")
		style = mailAlert + mailAlerts[kind] + ";"
	case n.DataAtom == atom.Code && n.Parent != nil && n.Parent.DataAtom == atom.Pre:
		style = "font-family:inherit;font-size:inherit;"
	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;"
	}
	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 style != "" {
		n.Attr = append(n.Attr, html.Attribute{Key: "style", Val: style})
	}
}