Skip to content
quarry Artifact [c7771695dd]
Login

Artifact [c7771695dd]

Artifact c7771695dd64d3716d1eeab20bb576eb615ef078cf7642c01888ef853fe28b1f:


package api

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

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

// 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()
		p["mark"] = string(d.sourceMark(oltp.AvatarRepo, payloadStr(e.Payload, "repo")))
		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
	}
	repo, surface := payloadStr(e.Payload, "repo"), payloadStr(e.Payload, "surface")
	var body template.HTML
	if c.body {
		text := payloadStr(e.Payload, "text")
		if text == "" {
			return e
		}
		if body, ok = markup.EmailHTML([]byte(text), d.mailResolver(repo, surface, e.To), 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), d.mailResolver(repo, surface, e.To), d.link)
			if !ok {
				return e
			}
			body += h
		}
	}
	title := payloadStr(e.Payload, "title")
	doctitle := title
	if e.Workflow == "quarry-landing" {
		title = payloadStr(e.Payload, "summary")
		doctitle = title
	}
	kind := oltp.AvatarRepo
	if surface == "team chat" {
		kind = oltp.AvatarTeam
	}
	meta := now.UTC().Format("Mon 2 Jan, 15:04 UTC")
	if ref := payloadStr(e.Payload, "ref"); ref != "" {
		meta += " ยท " + ref
	}
	doc := markup.EmailDoc{
		Title:   doctitle,
		Mark:    d.sourceMark(kind, repo),
		Name:    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"),
	}
	d.brandCard(&doc)
	if e.Workflow == "quarry-landing" {
		doc.Context = d.landingContext(e.Payload)
	}
	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
}

// mailResolver is the reference resolver the page for this message uses,
// so #N, !N, hashes and [[wiki]] link to the same places in the mail as
// on the site: the repository's index, and chat's own reading of a bare
// #N on the chat surface. Cross-repository tokens resolve as they would
// for the recipient. A surface with no repository (a team's chat) resolves nothing.
func (d Deps) mailResolver(repo, surface, to string) markup.Resolver {
	r, ok := d.Repos.Get(repo)
	if !ok || r.Index == nil || surface == "team chat" {
		return resolver{}
	}
	// As the recipient, who is the one person this copy is for: a
	// reference they may not follow stays the sentence its author wrote.
	ctx := context.Background()
	if to != "" && !strings.HasPrefix(to, "repo:") {
		ctx = ops.WithUser(ctx, to)
	}
	res := d.refTokenResolver(ctx, r.Index, repo)
	res.chat = surface == "chat"
	return res
}

// sourceMark is the header's mark for what an email came from: its
// uploaded avatar at the one address the forge serves it from, else the
// identicon drawn in cells. The address carries nothing per recipient.
// A repository the public cannot read has no avatar to serve an
// unauthenticated mail client, so it gets the identicon too.
func (d Deps) sourceMark(kind oltp.AvatarKind, name string) template.HTML {
	if d.Origin != "" && name != "" {
		path := "/t/"
		if kind == oltp.AvatarRepo {
			path = "/r/"
		}
		readable := kind != oltp.AvatarRepo
		if !readable {
			_, err := d.repoFor(context.Background(), name)
			readable = err == nil
		}
		if d.Avatars != nil {
			if _, ok, err := d.Avatars.AvatarOf(kind, name); readable && err == nil && ok {
				return markup.AvatarMark(d.link(path+url.PathEscape(name)+"/avatar"), name)
			}
		}
		// The forge's own repository wears the forge's logo until an
		// avatar replaces it: the site's rule (web's builtinMark), by name.
		if kind == oltp.AvatarRepo && name == ownRepo {
			return markup.LogoMark(d.logoURL())
		}
	}
	return markup.IdenticonMark(name)
}

// ownRepo is the forge's own repository, which the site draws with the
// forge's logo rather than an identicon (web.builtinMark).
const ownRepo = "quarry"

// logoURL is the forge's logo, public at one address for every recipient.
func (d Deps) logoURL() string { return d.link("/apple-touch-icon.png") }

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

// accountMail is one message about the account or the application
// itself, in the card's terms: what it says, where it leads, and why
// this person has it. Every such message is built as one, and sendMail
// is the one place that sends it.
type accountMail struct {
	Subject string
	Text    string        // the plain-text part, as written
	Context template.HTML // the line naming what happened; the subject when empty
	Body    template.HTML
	Open    string // the button, when there is somewhere to go
	URL     string
	Why     string // why this person got it
}

// card renders m as the application's card (quarry's logo and wordmark,
// no repository). "" when it cannot be built; the message then goes as text.
func (d Deps) card(m accountMail) string {
	ctx := m.Context
	if ctx == "" {
		ctx = template.HTML(strong(m.Subject))
	}
	why := m.Why
	if why == "" {
		why = "You're getting this because it is about your quarry account."
	}
	doc := markup.EmailDoc{
		Title: m.Subject, Surface: "account", Context: ctx,
		Body: m.Body, Open: m.Open, OpenURL: m.URL, Why: why,
	}
	d.brandCard(&doc)
	h, ok := markup.EmailDocument(doc)
	if !ok {
		return ""
	}
	return h
}

// brandCard gives a card the forge's own logo and wordmark and the home
// link, where an origin lets them be absolute.
func (d Deps) brandCard(doc *markup.EmailDoc) {
	if d.Origin == "" {
		return
	}
	doc.HomeURL, doc.HomeName = d.link(""), d.originHost()
	doc.LogoURL, doc.WordmarkURL = d.logoURL(), d.wordmarkURL()
}

// wordmarkURL is the application's name as a PNG, public at one address.
func (d Deps) wordmarkURL() string { return d.link("/wordmark.png") }

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

// landingContext is a landing email's headline: the summary, with the
// words "landing 768" drawn as the state icon and the number when the
// state is known (mail can show the PNG the pages' glyph stands for).
func (d Deps) landingContext(p map[string]any) template.HTML {
	summary, from := payloadStr(p, "summary"), payloadStr(p, "from")
	n, _ := p["number"].(int)
	state := payloadStr(p, "state")
	lead := ""
	if from != "" && strings.HasPrefix(summary, from+" ") {
		lead, summary = strong(from), summary[len(from):]
	}
	words := "landing " + strconv.Itoa(n)
	i := strings.Index(summary, words)
	if n == 0 || state == "" || i < 0 {
		return template.HTML(lead + template.HTMLEscapeString(summary))
	}
	alt := words + ", " + state
	img := `<img src="` + template.HTMLEscapeString(d.link("/icons/landing-"+state+".png")) + `" alt="` + alt +
		`" width="14" height="14" style="display:inline-block;width:14px;height:14px;border:0;vertical-align:-2px;margin-right:3px;">` + strconv.Itoa(n)
	return template.HTML(lead + template.HTMLEscapeString(summary[:i]) + img + template.HTMLEscapeString(summary[i+len(words):]))
}