package notify
import (
"html/template"
"strings"
"craftdesign.group/quarry/internal/markup"
)
// Workflows returns the definitions quarry provisions at startup (RFC
// 0011 §7). These are the same six workflows docs/running.md used to
// ask an operator to `curl` by hand — this is now the one source,
// created by EnsureWorkflow rather than typed.
//
// RFC 0011 §6's routing table is spelled here and nowhere else:
//
// quarry-mention in_app -> push -> email (immediate; email accepts replies)
// quarry-forum-reply in_app -> push -> email (immediate; email accepts replies)
// quarry-landing in_app -> push -> email (immediate; email accepts replies)
// quarry-activity digest 2 h -> in_app -> email
// quarry-signoff in_app -> push -> email (immediate; email ON: a request waits on the owner)
// quarry-error/slow in_app (admins; unchanged)
// quarry-ops in_app -> push (admins; the host's alarms, scripts/quarry-alert.sh)
// quarry-ops-alert in_app -> push -> email (a site admin who is a person; the forge's own
// operational alerts, once at start and once at clear; email ON)
//
// Email is OFF by default for every family: notify reads an absent
// preference as enabled, so the forge writes the explicit "no" for each
// subscriber who has not chosen (emailOffByDefault in cmd/quarry). The
// step exists so the choice exists; nobody is mailed for not having
// made it.
//
// These definitions are quarry's: EnsureWorkflow rewrites an existing
// forge's copy in place (same workflow id) whenever they change here,
// so an edit belongs in this file, not in notify's console.
func Workflows() []WorkflowDef {
// Control values are notify's own per-step-type shapes, read off
// internal/steptype (channels.go for in_app/push, sleep.go for
// digest). They are typed map[string]any rather than map[string]string
// because a digest's `amount` is a NUMBER on notify's side: a
// quoted "15" fails its json.Unmarshal into int64 and the step
// never compiles.
// The redirect is what makes an inbox row a link. notify stores it
// on the message as extras.redirect.url, which is exactly where
// Message.Link reads from, and the inbox renders a row with no link
// as dead text — eight rows of "something happened" leading
// nowhere. Every trigger quarry sends carries payload.link for this
// reason; the push step has always used it.
inApp := func(id, subject, body string) WorkflowStep {
return WorkflowStep{
StepID: id,
Type: "in_app",
ControlValues: map[string]any{
"subject": subject,
"body": body,
"redirect": map[string]any{"url": "{{payload.link}}"},
},
}
}
// push carries the link in data_json, which notify's push step
// validates as a JSON object of STRING values (steptype.push) and
// passes to the browser as the notification's data — where sw.js
// reads data.url to decide what a click opens.
push := func(id, title, body string) WorkflowStep {
return WorkflowStep{
StepID: id,
Type: "push",
ControlValues: map[string]any{
// BOTH spellings, deliberately. notify's push editor
// authors "title", but the envelope its delivery
// renders from (runenv.StepControls) carries only
// "subject" — so a push authored the documented way
// arrives with an empty title and the service worker
// falls back to the word "quarry" for every
// notification (notify#334). "subject" is what renders
// today; "title" is what the editor will show an
// operator, and what will keep working when the two
// spellings meet.
"title": title,
"subject": title,
"body": body,
"data_json": `{"url":"{{payload.link}}"}`,
},
}
}
// rich is an email's HTML alternative for a payload whose html field
// is markdown the forge rendered (api.withMailHTML). notify inserts it
// as sent, and an event without the field renders it empty, which
// notify sends as text alone: a trigger that omits html is unchanged.
const rich = `{{raw "payload.html"}}`
// email is plain text, one link, and — where the notification is
// something a person answers — accepts replies: notify then sends it
// with a tokenised Reply-To and hands the answer back on the
// delivery stream (RFC 0014 Phase 1). A digest is not answered, so
// the activity email does not opt in.
// digestHTML is a digest's card, built by the same markup.EmailDocument
// as every other quarry email. The batch is notify's to loop over, so
// the body and the lines naming the forge are Handlebars; the three
// href values go through html/template's URL filter, which would
// mangle braces, so they are placeholders swapped in afterwards.
// where names the batch's place ("" for a repository, "#" for its
// chat channel); noun is what the batch counts; open and why are the
// button and the footer's reason.
digestHTML := func(surface, where, noun, open, why string, opts ...string) string {
const link, settings, home, logo, badge, badgeSm = "https://q.invalid/link", "https://q.invalid/settings", "https://q.invalid/home", "https://q.invalid/logo", "https://q.invalid/badge", "https://q.invalid/badge-sm"
name := "{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}"
// opts: the heading, when "N things in this repository" is not
// what the batch is, and where the button goes.
heading := "{{#if digest.events.[1]}}<strong>{{digest.count}} " + noun + "</strong> in <strong>" + where + name + "</strong>" +
"{{else}}<strong>{{payload.actor}}</strong> in <strong>" + where + name + "</strong>{{/if}}"
target := "{{payload.link}}"
if len(opts) == 2 {
heading, target = opts[0], opts[1]
}
h, _ := markup.EmailDocument(markup.EmailDoc{
Title: "quarry " + surface,
Mark: `{{raw "payload.mark"}}`,
Name: name,
Surface: surface,
Context: template.HTML(heading),
Body: "{{#if digest.events.[1]}}<ul style=\"margin:0 0 14px;padding-left:22px;\">" +
"{{#each digest.events}}<li style=\"margin:0 0 6px;\"><strong>{{this.actor}}</strong> {{this.summary}} " +
"<a class=\"q-a\" href=\"{{this.link}}\" style=\"color:#a04412;\">open</a></li>{{/each}}</ul>" +
"{{else}}<p style=\"margin:0 0 12px;\">{{payload.summary}}</p>{{/if}}",
Open: open, OpenURL: link,
Why: strings.ReplaceAll(why, "NAME", where+name),
SettingsURL: settings, HomeURL: home, LogoURL: logo, BadgeURL: badge, BadgeSmallURL: badgeSm, HomeName: "{{payload.host}}",
})
return strings.NewReplacer(
link, target,
settings, "{{payload.origin}}/me#notifications",
home, "{{payload.origin}}",
// The images carry their content hash, which only the forge knows:
// it puts the whole addresses in the payload.
badgeSm, "{{payload.badgesm}}", // before badge, which prefixes it
badge, "{{payload.badge}}",
logo, "{{payload.logo}}",
).Replace(h)
}
email := func(id, subject, body string, reply bool, html ...string) WorkflowStep {
cv := map[string]any{"subject": subject, "body": body}
if len(html) > 0 {
cv["htmlBody"] = html[0]
}
if reply {
cv["reply"] = true
}
return WorkflowStep{StepID: id, Type: "email", ControlValues: cv}
}
return []WorkflowDef{
{
// What changed on the forge: one inbox item and a push per
// announced changelog entry, to everyone with an account.
Name: "quarry-changelog",
TriggerIdentifier: "quarry-changelog",
Steps: []WorkflowStep{
inApp("S1", "What changed in quarry: {{payload.title}}",
"{{payload.from}} published a changelog entry — {{payload.link}}"),
push("S2", "What changed in quarry", "{{payload.title}}"),
},
},
{
Name: "quarry-mention",
TriggerIdentifier: "quarry-mention",
Steps: []WorkflowStep{
inApp("S1", "{{payload.from}} mentioned you in {{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}",
"{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}} — {{#if payload.plain}}{{payload.plain}}{{else}}{{payload.text}}{{/if}}"),
push("S2", "{{payload.from}} mentioned you",
"{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}} — {{#if payload.plain}}{{payload.plain}}{{else}}{{payload.text}}{{/if}}"),
email("S3", "{{payload.from}} mentioned you in {{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}",
"{{payload.from}} wrote:\n\n{{#if payload.plain}}{{payload.plain}}{{else}}{{payload.text}}{{/if}}\n\n{{payload.link}}", true, rich),
},
},
{
// The author's copy of a decision. The reason is the whole
// payload: it already exists in the public modlog, where it
// is addressed to everybody and therefore to nobody. This
// is where it becomes a message to the person it is about.
Name: "quarry-moderation",
TriggerIdentifier: "quarry-moderation",
// IN-APP ONLY, deliberately. An email step must belong to a
// preference family (EmailFamilies) or it is mail nobody can
// turn off, and adding a fifth family is a change to the
// notification matrix rather than to this workflow. The
// defect being fixed is the missing INBOX row; email can
// follow with the preference that makes it optional.
Steps: []WorkflowStep{
inApp("S1", "{{payload.title}}", "{{payload.text}}"),
},
},
{
Name: "quarry-forum-reply",
TriggerIdentifier: "quarry-forum-reply",
Steps: []WorkflowStep{
inApp("S1", "{{payload.from}} replied in {{payload.title}}",
"{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}} — {{#if payload.plain}}{{payload.plain}}{{else}}{{payload.text}}{{/if}}"),
push("S2", "{{payload.from}} replied in {{payload.title}}",
"{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}} — {{#if payload.plain}}{{payload.plain}}{{else}}{{payload.text}}{{/if}}"),
email("S3", "{{payload.from}} replied in {{payload.title}}",
"{{payload.from}} wrote:\n\n{{#if payload.plain}}{{payload.plain}}{{else}}{{payload.text}}{{/if}}\n\n{{payload.link}}", true, rich),
},
},
{
// The one workflow with a digest: activity is a stream of
// small things, and a phone that buzzes per check-in is a
// phone somebody turns off. Fifteen minutes is RFC 0011 §6's
// window; the digest runs BEFORE the in_app step, so what
// lands in the inbox is the batch rather than each event.
Name: "quarry-activity",
TriggerIdentifier: "quarry-activity",
Steps: []WorkflowStep{
{
StepID: "S1",
Type: "digest",
ControlValues: map[string]any{
"type": "regular",
// Two hours, which is the number somebody who
// has not asked for anything should get: often
// enough to be news, rare enough that a busy
// repository does not become the reason people
// mute the forge. It is the DEFAULT, not the
// policy — a per-person window is notify#336.
"amount": 2,
"unit": "hours",
// Per REPOSITORY, not per person. Without it one
// window batches every repository somebody can
// see into a single row — "7 updates" across
// four projects, which answers no question
// anybody has.
//
// ROOTED at payload: notify resolves a
// single-segment path to the empty string and
// then merges every event into the base bucket,
// silently. "repo" looks right, provisions
// without complaint, and digests nothing.
"digestKey": "payload.repo",
},
},
// A digest is a COUNT and its parts; a single event is
// just that event. Both arrive through this one step, so
// the copy tells them apart rather than reading "1
// updates" for the commonest case. events.[1] is the
// only "more than one" test Handlebars gives us without
// a helper.
inApp("S2",
"{{#if digest.events.[1]}}{{digest.count}} updates in {{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}"+
"{{else}}{{payload.summary}}{{/if}}",
"{{#if digest.events.[1]}}{{#each digest.events}}{{this.actor}} {{this.summary}}\n{{/each}}"+
"{{else}}{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}} — by {{payload.actor}}{{/if}}"),
// One line per event, each with its own link; a single
// event reads as itself, as the inbox row does.
email("S3",
"{{#if digest.events.[1]}}{{digest.count}} updates in {{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}"+
"{{else}}{{payload.summary}}{{/if}}",
"{{#if digest.events.[1]}}{{#each digest.events}}{{this.actor}} {{this.summary}}\n{{this.link}}\n\n{{/each}}"+
"{{else}}{{payload.actor}} {{payload.summary}}\n\n{{payload.link}}{{/if}}", false, digestHTML("activity", "", "updates", "Open the activity",
"You're getting this because you follow activity in NAME.")),
},
},
{
// Every message in a chat channel, for somebody who asked
// for it (the channel's bell, api/chatnotify.go). A channel is
// conversation, so the window is short: thirty minutes, per
// channel, the same digestKey the activity workflow explains.
Name: "quarry-chat",
TriggerIdentifier: "quarry-chat",
Steps: []WorkflowStep{
{
StepID: "S1",
Type: "digest",
ControlValues: map[string]any{
"type": "regular",
"amount": 30,
"unit": "minutes",
"digestKey": "payload.repo",
},
},
inApp("S2",
"{{#if digest.events.[1]}}{{digest.count}} messages in #{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}"+
"{{else}}{{payload.actor}} in #{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}{{/if}}",
"{{#if digest.events.[1]}}{{#each digest.events}}{{this.actor}}: {{this.summary}}\n{{/each}}"+
"{{else}}{{payload.summary}}{{/if}}"),
email("S3",
"{{#if digest.events.[1]}}{{digest.count}} messages in #{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}"+
"{{else}}{{payload.actor}} in #{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}{{/if}}",
"{{#if digest.events.[1]}}{{#each digest.events}}{{this.actor}}: {{this.summary}}\n{{this.link}}\n\n{{/each}}"+
"{{else}}{{payload.actor}}: {{payload.summary}}\n\n{{payload.link}}{{/if}}", false, digestHTML("chat", "#", "messages", "Open the channel",
"You're getting this because you follow every message in NAME. Change it with the bell in the channel.")),
},
},
{
Name: "quarry-landing",
TriggerIdentifier: "quarry-landing",
Steps: []WorkflowStep{
inApp("S1", "{{payload.summary}}", "{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}} — {{payload.link}}"),
push("S2", "{{payload.summary}}", "{{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}"),
email("S3", "{{payload.summary}}",
"{{payload.summary}} in {{#if payload.repoName}}{{payload.repoName}}{{else}}{{payload.repo}}{{/if}}\n\n{{payload.link}}{{#if payload.approve}}\n\nAccept: {{payload.approve}}{{/if}}", true, rich),
},
},
{
// An agent asks its owner to approve a deed act (delete, un-serve,
// hand on). Nothing happens until the owner answers, so the
// request must reach them, not wait on a page they have to think
// to open: its email is not one of EmailFamilies, so it is on by
// default, unlike every family a person opts into (dmestas,
// 2026-11-01).
Name: "quarry-signoff",
TriggerIdentifier: "quarry-signoff",
Steps: []WorkflowStep{
inApp("S1", "{{payload.from}} asks you to approve: {{payload.summary}}", "{{payload.link}}"),
push("S2", "{{payload.from}} asks you to approve", "{{payload.summary}}"),
},
},
{
// The email for those requests, apart because notify merges
// events only when the digest is a workflow's first step, and
// the inbox row and the push must not wait: a burst from one
// agent is one email (RFC 0028), five minutes per agent. One
// request goes to its own page; several list each with its own
// link, and the button goes to the Waiting tab.
Name: "quarry-signoff-email",
TriggerIdentifier: "quarry-signoff-email",
Steps: []WorkflowStep{
{
StepID: "S1",
Type: "digest",
ControlValues: map[string]any{
"type": "regular",
"amount": 5,
"unit": "minutes",
"digestKey": "payload.from",
},
},
email("S2",
"{{#if digest.events.[1]}}{{digest.count}} requests from {{payload.from}} to approve{{else}}Approve? {{payload.from}} asks to {{payload.summary}}{{/if}}",
"{{#if digest.events.[1]}}{{payload.from}}, an agent you answer for, is waiting on {{digest.count}} requests. Nothing happens until you approve or deny them:\n\n{{#each digest.events}}{{this.summary}}\n{{this.link}}\n\n{{/each}}All of them: {{payload.waiting}}"+
"{{else}}{{payload.from}}, an agent you answer for, asks to {{payload.summary}}.\n\nNothing happens until you approve or deny it:\n{{payload.link}}{{/if}}",
false, digestHTML("signoff", "", "requests", "Review and approve",
"You're getting this because an agent you answer for asked for something only you may do, and nothing happens until you answer.",
"{{#if digest.events.[1]}}<strong>{{digest.count}} requests</strong> from <strong>{{payload.from}}</strong>{{else}}<strong>{{payload.from}}</strong> asks you to approve{{/if}}",
"{{#if digest.events.[1]}}{{payload.waiting}}{{else}}{{payload.link}}{{/if}}")),
},
},
{
// An operational alert from the forge itself to a site admin
// who is a person: memory about to run out, a server short of
// it. Sent once when the condition starts and once when it
// clears (api/opsalert.go), so it is not a digest, and
// immediate because the point is to act now. Its email is on
// for admins like the sign-off's (RequiredEmail); a person
// turns a channel off in the matrix's operations row.
Name: "quarry-ops-alert",
TriggerIdentifier: "quarry-ops-alert",
Steps: []WorkflowStep{
inApp("S1", "{{payload.title}}", "{{payload.text}}"),
push("S2", "{{payload.title}}", "{{payload.summary}}"),
email("S3", "{{payload.title}}", "{{payload.text}}\n\n{{payload.link}}", false, rich),
},
},
{
Name: "quarry-error",
TriggerIdentifier: "quarry-error",
Steps: []WorkflowStep{inApp("S1",
"quarry error on {{payload.surface}}",
"{{payload.path}} — {{payload.summary}}",
)},
},
{
// A push a person asks for, to find out whether push
// reaches this device at all. Push ONLY: a test that also
// wrote to the inbox would make the inbox worse every time
// somebody checked their phone.
Name: "quarry-test",
TriggerIdentifier: "quarry-test",
Steps: []WorkflowStep{push("S1", "quarry",
"Test notification — push reaches this device.")},
},
{
// The host's own alarms, sent by scripts/quarry-alert.sh
// rather than the forge: a deploy that failed or rolled
// back, a forge unit that crashed, a deploy that never
// finished. Addressed to the admins topic. Push, because
// the point is to reach somebody who is not at the
// terminal; no email, which would need a preference
// family (EmailFamilies).
Name: "quarry-ops",
TriggerIdentifier: "quarry-ops",
Steps: []WorkflowStep{
inApp("S1", "{{payload.summary}}", "{{payload.text}}"),
push("S2", "{{payload.summary}}", "{{payload.text}}"),
},
},
{
Name: "quarry-slow",
TriggerIdentifier: "quarry-slow",
Steps: []WorkflowStep{inApp("S1",
"slow page: {{payload.path}}",
"{{payload.summary}}",
)},
},
}
}
// EmailFamilies are the workflows that carry an email step a person
// may switch on: the four families of quarry's preference matrix. Email
// is the one channel that is off until chosen, and this is the list the
// forge writes that choice against.
func EmailFamilies() []string {
return []string{"quarry-mention", "quarry-forum-reply", "quarry-landing", "quarry-activity"}
}
// RequiredEmail are the workflows whose email is not a family a person
// opts into but on for everyone: a request that waits on its recipient,
// whom nothing else would reach; and a channel's every-message digest,
// which reaches only somebody who asked for it with the channel's bell,
// so the asking is the opt-in; and an operational alert, which reaches
// only site admins who are people. emailOffByDefault leaves them alone.
func RequiredEmail() []string {
return []string{"quarry-signoff-email", "quarry-chat", "quarry-ops-alert"}
}