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