// transactional.go is the seam for mail that is about the ACCOUNT
// itself — its access, its address, its credentials — rather than
// about content a person opted into. This mail carries no preference
// and no unsubscribe: an account changing hands, or its owner losing
// its way to a repository, silently is not an acceptable outcome, so
// nothing here goes through notify's matrix (internal/notify/workflows.go),
// where every family is a choice by design. A forge with no mail
// provider simply sends none of it, the same as it sends no invite.
//
// The qualifying messages, today:
// - an invite (register.go's account.invite, already the pattern
// these follow)
// - access granted, changed or revoked on a repository (email.go's
// grant/revoke ops and AccessChanged below)
// - an email address confirmation (email.go's me.email.set)
// - the address itself changing, told to the address it is leaving
// (email.go's me.email.confirm and account.email.set)
// - a password change, told to the account's own confirmed address
// (register.go's me.passwd)
// - an invite redeemed, told to the accounts --join-notify names
// (register.go's account.claim): whoever runs the invitations, told
// when the person they asked in has set a password and can sign in
//
// Adding to this list is a decision, not a convenience: every entry
// exists because a person silently losing track of their own account
// is worse than one more email. It is not where a mention, a reply or
// an activity digest belongs — those stay content, and stay opt-out
// only through notify.
package api
import (
"context"
"fmt"
html2 "html"
"html/template"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
)
// sendMail is the ONE place an account or application email leaves the
// forge: m's card, with its text as the plain part. Nothing else calls
// Deps.Mail (TestMailSendHasOneCallSite holds it to that), so a message
// cannot ship without the card.
func (d Deps) sendMail(ctx context.Context, address string, m accountMail) error {
if d.Mail == nil || address == "" {
return fmt.Errorf("no mail to send: %w", ops.ErrNotFound)
}
_, err := d.Mail.Send(ctx, address, m.Subject, m.Text, d.card(m))
return err
}
// mailTo sends one message to an explicit address — the primitive
// beneath mailAccount, and what a caller reaches for directly when the
// address in question is NOT (yet, or any longer) the account's
// confirmed one: a pending address waiting on its confirmation link,
// or a just-superseded one being told so.
func (d Deps) mailTo(ctx context.Context, address string, m accountMail) bool {
return d.sendMail(ctx, address, m) == nil
}
// mailAccount sends one transactional message to account's CONFIRMED
// address — resolved here so every caller says WHO, never an address
// it read off a stale copy. It reports whether the message went, never
// an error: a missing mail plane or a never-confirmed address is not a
// failure of the op that asked (an invite still returns its link, a
// caps change still takes effect) — only a fact the caller may use to
// word a reply or a log line.
//
// Loads the whole roster to find one row, the same trade mentionKnown
// already accepts (register.go): forge rosters here are small, and an
// indexed lookup is not worth building until one is not.
func (d Deps) mailAccount(ctx context.Context, account string, m accountMail) bool {
if d.Accounts == nil || account == "" {
return false
}
accts, err := d.Accounts.Accounts()
if err != nil {
return false
}
for _, a := range accts {
if a.Name == account {
return d.mailTo(ctx, a.Email, m)
}
}
return false
}
// InviteRedeemed tells the accounts named by --join-notify that the
// invited account name has set its password and can sign in. The forge
// does not record which admin sent a given invite, so who hears is the
// operator's choice rather than a guess; an unset flag tells nobody. The
// person who just joined is never told about their own action, and an
// account with no confirmed address is skipped.
func (d Deps) InviteRedeemed(ctx context.Context, name string) int {
if name == "" || len(d.JoinNotify) == 0 {
return 0
}
base := d.base("")
m := accountMail{
Kind: kindInviteRedeemed,
Subject: name + " accepted their invitation",
Text: name + " set a password and can now sign in.",
Context: template.HTML(strong(name) + " accepted their invitation"),
Body: template.HTML("<p style=\"margin:0 0 12px;\">" + html2.EscapeString(name) + " set a password and can now sign in.</p>"),
Why: "You're getting this because you are named to hear when an invitation is accepted.",
}
if base != "" {
m.Text += "\n\n" + base + "/fleet/people"
m.Open, m.URL = "People on this forge", base+"/fleet/people"
}
sent := 0
for _, who := range d.JoinNotify {
if who != name && d.mailAccount(ctx, who, m) {
sent++
}
}
return sent
}
// AccessChanged tells the PERSON their standing changed. The matching
// vocabulary event is raised at the write path (see raiseAccountChange),
// not here. before/after are ROLE NAMES ("reader", "developer",
// "maintainer", "admin", "custom" or "" for none) — the caller's job to
// resolve from whatever it holds (a role string already, or a raw caps
// string read through oltp.Infer), because a fossil capability letter
// means nothing to a person and this function only ever speaks the
// words a role was granted, changed or lost in.
//
// Silent on purpose when: there is no repository or no subject to
// tell (account, repo empty), nobody made the change on record (actor
// empty), the actor and the subject are the same account (nobody needs
// to be told about their own action), or before == after (nothing
// changed — Grant re-asserting a role a person already held, or a
// revoke on someone who had no grant to begin with).
func (d Deps) AccessChanged(ctx context.Context, actor, account, repo, before, after string) {
if account == "" || repo == "" || actor == "" || actor == account || before == after {
return
}
var summary, link string
switch {
case before == "":
summary = actor + " gave you " + after + " on " + repo
link = d.link("/r/" + repo)
case after == "":
summary = actor + " removed your access to " + repo
// No link: a revoke has nowhere left to send anyone.
default:
summary = actor + " changed your access to " + repo
link = d.link("/r/" + repo)
}
// The vocabulary EVENT is not raised here. It used to be, and that
// made two raises for one change once the write paths grew their
// own: a webhook heard a grant twice, and heard account.caps for an
// arrival that should have been account.added. Deps.raiseAccountChange
// is the single raise point, at the write; this function's job is
// the half its name describes — telling the person it happened to.
//
// Attention, not notify: this is account mail, and account mail
// does not ride the notify plane (see the package comment above).
// The inbox entry is the one thing here that IS a preference-free
// notification rather than mail, and RFC 0011 §5 says it belongs
// on the attention stream regardless.
d.attend(account, AttentionEvent{
Kind: "access", Repo: repo, From: actor, Summary: summary, Link: link,
At: time.Now().UTC().Format(time.RFC3339Nano),
})
body := summary + "."
if link != "" {
body += "\n\n" + link
}
m := accountMail{
Kind: kindAccessChanged,
Subject: summary, Text: body,
Context: template.HTML(strong(actor) + html2.EscapeString(strings.TrimPrefix(summary, actor))),
Why: "You're getting this because your access on " + repo + " changed.",
}
if link != "" {
m.Open, m.URL = "Open "+repo, link
}
d.mailAccount(ctx, account, m)
}
// TeamMembership tells the PERSON they were put in a team.
//
// A team grants a capability floor on EVERY repository under it, so an
// addition can hand someone access to work they have never seen — and
// on a private repository it is the whole of their access. That is the
// case this list exists for: being quietly given standing somewhere is
// the kind of thing a person should not have to discover.
//
// Only a NEW membership. team.member is also how an owner edits the
// caps or the listing of somebody already in the team, and mailing on
// those would turn one decision into a stream of notices about flags
// the person never chose.
//
// Silent on the same terms as AccessChanged: nobody to tell, nobody on
// record as having done it, or somebody adding themselves.
func (d Deps) TeamMembership(ctx context.Context, actor, account, team string) {
if account == "" || team == "" || actor == "" || actor == account {
return
}
summary := actor + " added you to the team " + team
link := d.link("/t/" + team)
d.attend(account, AttentionEvent{
Kind: "access", From: actor, Summary: summary, Link: link,
At: time.Now().UTC().Format(time.RFC3339Nano),
})
body := summary + ".\n\nA team grants what it holds on every repository under it."
if link != "" {
body += "\n\n" + link
}
d.mailAccount(ctx, account, accountMail{
Kind: kindTeamMembership,
Subject: summary, Text: body,
Context: template.HTML(strong(actor) + html2.EscapeString(strings.TrimPrefix(summary, actor))),
Body: "<p style=\"margin:0 0 12px;\">A team grants what it holds on every repository under it.</p>",
Open: "Open " + team, URL: link,
Why: "You're getting this because you were added to the team " + team + ".",
})
}