Skip to content
quarry Artifact [06a11825f9]
Login

Artifact [06a11825f9]

Artifact 06a11825f992aaf76c8df78484f27557b4b2298966cd5d34c1eda8681aef9d03:


package api

import (
	"html/template"
	"net/url"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/markup"
)

// mailCard is what one workflow's email says around its payload: the
// verb in the context line, what the recipient did to deserve it, and
// whether the workflow takes replies (it must agree with the reply flag
// on the workflow's email step, internal/notify/workflows.go).
type mailCard struct {
	context func(from, repo, title string) template.HTML
	why     func(repo string) string
	reply   bool
	body    bool // the payload's text is rendered as the body
	// message is the payload key whose markdown is an optional body; the
	// payload's title then heads it.
	message string
}

func strong(s string) string { return "<strong>" + template.HTMLEscapeString(s) + "</strong>" }

var mailCards = map[string]mailCard{
	"quarry-mention": {
		context: func(from, repo, _ string) template.HTML {
			return template.HTML(strong(from) + " mentioned you in " + strong(repo))
		},
		why:   func(repo string) string { return "You're getting this because you were mentioned in " + repo + "." },
		reply: true, body: true,
	},
	"quarry-forum-reply": {
		context: func(from, _, title string) template.HTML {
			return template.HTML(strong(from) + " replied in " + strong(title))
		},
		why:   func(repo string) string { return "You're getting this because you follow this thread in " + repo + "." },
		reply: true, body: true,
	},
	"quarry-landing": {
		// The summary already names who did what to which landing.
		context: func(from, _, summary string) template.HTML {
			if from != "" && strings.HasPrefix(summary, from+" ") {
				return template.HTML(strong(from) + template.HTMLEscapeString(summary[len(from):]))
			}
			return template.HTML(template.HTMLEscapeString(summary))
		},
		why:   func(repo string) string { return "You're getting this because it concerns you in " + repo + "." },
		reply: true, message: "message",
	},
}

// mailReply is what a reply to the email does, by the surface the link
// opens. A surface not listed gets no hint: nothing promises where its
// answer would land.
var mailReply = map[string]string{
	"chat": "answer in chat", "team chat": "answer in the team chat",
	"forum": "reply in the forum", "landing": "comment on the landing",
}

// mailOpen is the button's words, by the surface the link opens.
var mailOpen = map[string]string{
	"chat": "Open the message", "team chat": "Open the chat", "forum": "Open the post",
	"landing": "Open the landing", "ci": "Open the run",
}

func payloadStr(p map[string]any, k string) string {
	s, _ := p[k].(string)
	return s
}

// withMailHTML adds payload.html to an email workflow's event: the whole
// card (markup.EmailDocument) around the same rendering and sanitizing
// the pages use, made mail-safe. The text body stays as the workflow
// wrote it. It adds nothing without an origin (links could not be
// absolute) or when the document would be too large, and notify then
// sends text only. The payload is copied, like absoluteLink's.
func (d Deps) withMailHTML(e NotifyEvent, now time.Time) NotifyEvent {
	if e.Workflow == "quarry-activity" && d.Origin != "" {
		// The digest's card is written in notify (notify.Workflows), once
		// for the batch; it names the forge through the payload.
		p := make(map[string]any, len(e.Payload)+2)
		for k, v := range e.Payload {
			p[k] = v
		}
		p["origin"], p["host"] = d.link(""), d.originHost()
		e.Payload = p
		return e
	}
	c, ok := mailCards[e.Workflow]
	link := payloadStr(e.Payload, "link")
	if !ok || d.Origin == "" || !strings.HasPrefix(link, "http") {
		return e
	}
	var body template.HTML
	if c.body {
		text := payloadStr(e.Payload, "text")
		if text == "" {
			return e
		}
		if body, ok = markup.EmailHTML([]byte(text), resolver{}, d.link); !ok {
			return e
		}
	}
	if c.message != "" {
		// A landing: its title as the heading, then its message through
		// the same path as a mention's.
		if t := payloadStr(e.Payload, "title"); t != "" {
			body += template.HTML(`<p style="margin:0 0 12px;font-size:17px;font-weight:600;line-height:1.35;">` +
				template.HTMLEscapeString(t) + "</p>")
		}
		if m := payloadStr(e.Payload, c.message); m != "" {
			h, ok := markup.EmailHTML([]byte(m), resolver{}, d.link)
			if !ok {
				return e
			}
			body += h
		}
	}
	repo, surface := payloadStr(e.Payload, "repo"), payloadStr(e.Payload, "surface")
	title := payloadStr(e.Payload, "title")
	doctitle := title
	if e.Workflow == "quarry-landing" {
		title = payloadStr(e.Payload, "summary")
		doctitle = title
	}
	meta := now.UTC().Format("Mon 2 Jan, 15:04 UTC")
	if ref := payloadStr(e.Payload, "ref"); ref != "" {
		meta += " ยท " + ref
	}
	doc := markup.EmailDoc{
		Title:   doctitle,
		Repo:    repo,
		Surface: surface,
		Context: c.context(payloadStr(e.Payload, "from"), repo, title),
		Meta:    meta,
		Body:    body,
		Open:    mailOpen[surface], OpenURL: link,
		Why:         c.why(repo),
		SettingsURL: d.link("/me#notifications"),
		HomeURL:     d.link(""), HomeName: d.originHost(),
	}
	if c.reply {
		doc.ReplyIn = mailReply[surface]
	}
	h, ok := markup.EmailDocument(doc)
	if !ok {
		return e
	}
	p := make(map[string]any, len(e.Payload)+1)
	for k, v := range e.Payload {
		p[k] = v
	}
	p["html"] = h
	e.Payload = p
	return e
}

// originHost is the forge's host for display, falling back to the origin.
func (d Deps) originHost() string {
	if u, err := url.Parse(d.Origin); err == nil && u.Host != "" {
		return u.Host
	}
	return d.Origin
}

// accountCard wraps the body of a mail about the account itself: no repo,
// no preference to point at, and a button when the mail has one job.
func (d Deps) accountCard(subject string, body template.HTML, open, openURL string) string {
	doc := markup.EmailDoc{
		Title: subject, Surface: "account",
		Context: template.HTML(strong(subject)),
		Body:    body, Open: open, OpenURL: openURL,
		Why: "You're getting this because it is about your quarry account.",
	}
	if d.Origin != "" {
		doc.HomeURL, doc.HomeName = d.link(""), d.originHost()
	}
	h, ok := markup.EmailDocument(doc)
	if !ok {
		return ""
	}
	return h
}

// landingMail is what a landing's email says beyond its one-line summary:
// the landing's title and its message (the record's prose, without the
// header zit writes), and for an accepted landing the merge's short hash.
// Empty where the index does not have them; the card then shows less.
func (d Deps) landingMail(repoName, id string, merged bool) (title, message, ref string) {
	if d.Repos == nil {
		return
	}
	r, ok := d.Repos.Get(repoName)
	if !ok || r.Index == nil {
		return
	}
	if l, found, err := r.Index.Landing(id); err == nil && found {
		title = l.Title
	}
	if body, found, err := r.Index.ForumPostBody(id); err == nil && found {
		message = index.LandingProse(body)
	}
	if merged {
		if ms, err := r.Index.MergesForLandings([]string{id}); err == nil && ms[id].Checkin != "" {
			ref = "landed as " + shortHash(ms[id].Checkin)
		}
	}
	return
}