Skip to content
quarry Artifact [3450494d15]
Login

Artifact [3450494d15]

Artifact 3450494d15b284f8d296a3f3d73422be2b779441eb67cc0cbdb4d1bd1a1e4ad5:


// 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)
//
// 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"
	"log"
	"time"

	"craftdesign.group/quarry/internal/evt"
)

// 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 is RFC 0005's account.caps moment, told to the person
// it happened to. 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)
	}
	if d.Events != nil {
		if err := d.Events.Raise(evt.AccountCaps, repo, map[string]any{
			"account": account, "actor": actor, "before": before, "after": after,
		}); err != nil {
			log.Printf("events: raise %s: %v", evt.AccountCaps, err)
		}
	}
	// 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>")
}