Skip to content
quarry Artifact [b913c1ee9e]
Login

Artifact [b913c1ee9e]

Artifact b913c1ee9e23d04ebe68ee5e75300e688c940256ae7d1fa6d08ee002cab8251a:


// 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{
		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{
		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{
		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 + ".",
	})
}