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