package api
import (
"context"
"html/template"
"net/url"
"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 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
}