// 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)
//
// 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"
html2 "html"
"time"
)
// 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, subject, text, html string) bool {
if d.Mail == nil || address == "" {
return false
}
_, err := d.Mail.Send(ctx, address, subject, text, html)
return err == 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, subject, text, html string) 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, subject, text, html)
}
}
return false
}
// 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
}
d.mailAccount(ctx, account, summary, body, "<p>"+html2.EscapeString(summary)+".</p>")
}