Skip to content
quarry Artifact [150c481b3e]
Login

Artifact [150c481b3e]

Artifact 150c481b3e90769a6a8cd2b9259f2d8d9400dd858056b7cb2ea4e084b91c0306:


// email.go wires the confirmed-address surface: a person setting or
// changing their own address (self-serve, proven by a one-time SIX-DIGIT
// CODE mailed to the new address and typed back on their own account
// page — never a link, which can be forwarded or clicked by whoever a
// browser hands it to), and the administrator's path for an existing
// account that has none or has the wrong one.
package api

import (
	"context"
	"fmt"
	htmlpkg "html"
	"strings"

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

// AccountEmail is the confirmed-address half of the account seam.
// Optional — an AccountStore that does not implement it has no email
// surface at all, and every op below answers unavailable rather than
// assuming a forge with none.
type AccountEmail interface {
	// AccountByName reads one account, for state a lookup by name
	// needs (its current confirmed and pending address) without
	// paying for the whole roster.
	AccountByName(name string) (Account, bool)
	// SetEmail stores an address CONFIRMED at once — the admin path,
	// which carries the same trust as typing someone's invite address.
	SetEmail(name, email string) error
	// SetPendingEmail records an address awaiting proof and mints its
	// one-time six-digit confirmation code.
	SetPendingEmail(name, address string) (code string, err error)
	// ConfirmEmail checks code against account's own live pending
	// code. There is never a reason to check anybody else's: the
	// caller is always the signed-in account confirming its own
	// address, which is what makes another account's code unreachable
	// by construction rather than by a separate ownership check.
	ConfirmEmail(account, code string) (address string, ok bool, err error)
	// ClearEmail blanks both the confirmed and pending address.
	ClearEmail(name string) error
}

func (d Deps) accountEmail() (AccountEmail, error) {
	if d.Accounts == nil {
		return nil, errNoAccounts()
	}
	ae, ok := d.Accounts.(AccountEmail)
	if !ok {
		return nil, fmt.Errorf("this forge's account store has no email surface: %w", ops.ErrUnavailable)
	}
	return ae, nil
}

// codeMail is the one piece of copy the confirmation code's email
// wears, shared by every place that mints one — today only
// me.email.set, but the wording belongs in one place regardless.
func codeMail(code string) (text, html string) {
	text = "Your quarry email confirmation code is " + code +
		".\n\nType it in on your account page. It works once and expires in 10 minutes. " +
		"If you did not ask for this, ignore it."
	html = "<p>Your quarry email confirmation code is <strong>" + htmlpkg.EscapeString(code) + "</strong>.</p>" +
		"<p>Type it in on your account page. It works once and expires in 10 minutes. " +
		"If you did not ask for this, ignore it.</p>"
	return text, html
}

type meEmailSetIn struct {
	Email string `json:"email" validate:"required,max=200"`
}

type meEmailSetOut struct {
	// Pending echoes the address a confirmation code was sent toward.
	Pending string `json:"pending"`
	// Sent means DELIVERED TO THE PROVIDER — see account.invite's
	// Emailed field for why that distinction matters.
	Sent bool `json:"sent"`
}

type meEmailConfirmIn struct {
	Code string `json:"code" validate:"required,min=6,max=6"`
}

type meEmailConfirmOut struct {
	Email string `json:"email"`
}

type meEmailClearIn struct{}

type meEmailIn struct{}

// meEmailOut is the page's read companion to the three writes above —
// none of them answer the account's CURRENT state, and the account
// page needs it to say "confirmed" or "waiting for <address>".
type meEmailOut struct {
	Email   string `json:"email"`
	Pending string `json:"pending"`
}

type accountEmailSetIn struct {
	Name  string `json:"name" validate:"required,max=64"`
	Email string `json:"email" validate:"required,max=200"`
}

type accountEmailSetOut struct {
	Name  string `json:"name"`
	Email string `json:"email"`
}

// registerAccountEmail wires the confirmed-address ops: the self-serve
// set/confirm/clear a signed-in account uses on itself (Auth Token,
// acting on ops.User(ctx) only — never another account), and the
// administrator's account.email.set for an existing account.
func registerAccountEmail(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[meEmailIn, meEmailOut]{
		Name:  "me.email",
		Doc:   "The signed-in account's email state: the confirmed address (empty if none) and one waiting for confirmation (empty if none).",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/email",
		Run: func(ctx context.Context, in meEmailIn) (meEmailOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return meEmailOut{}, fmt.Errorf("reading email state needs a signed-in account: %w", ops.ErrInvalid)
			}
			ae, err := d.accountEmail()
			if err != nil {
				return meEmailOut{}, err
			}
			a, _ := ae.AccountByName(who)
			return meEmailOut{Email: a.Email, Pending: a.EmailPending}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[meEmailSetIn, meEmailSetOut]{
		Name: "me.email.set",
		Doc: "Set or change the signed-in account's email. Nothing is confirmed yet: a six-digit code " +
			"goes to the NEW address, and the address quarry actually mails stays whatever was " +
			"confirmed before (if anything) until that code is typed back in.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/email",
		Run: func(ctx context.Context, in meEmailSetIn) (meEmailSetOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return meEmailSetOut{}, fmt.Errorf("setting an email needs a signed-in account: %w", ops.ErrInvalid)
			}
			if !strings.Contains(in.Email, "@") {
				return meEmailSetOut{}, fmt.Errorf("email must be an address: %w", ops.ErrInvalid)
			}
			ae, err := d.accountEmail()
			if err != nil {
				return meEmailSetOut{}, err
			}
			code, err := ae.SetPendingEmail(who, in.Email)
			if err != nil {
				return meEmailSetOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			text, html := codeMail(code)
			sent := d.mailTo(ctx, in.Email, "Your quarry email confirmation code", text, html)
			return meEmailSetOut{Pending: in.Email, Sent: sent}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[meEmailConfirmIn, meEmailConfirmOut]{
		Name: "me.email.confirm",
		Doc: "Promote the signed-in account's pending email to confirmed, spending the six-digit code. " +
			"Refuses a code that is wrong, spent, or expired; five wrong tries against one code burn it " +
			"outright, and either way the account's only way forward is a fresh me.email.set.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/email/confirm",
		Run: func(ctx context.Context, in meEmailConfirmIn) (meEmailConfirmOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return meEmailConfirmOut{}, fmt.Errorf("confirming an email needs a signed-in account: %w", ops.ErrInvalid)
			}
			ae, err := d.accountEmail()
			if err != nil {
				return meEmailConfirmOut{}, err
			}
			before, _ := ae.AccountByName(who)
			addr, ok, err := ae.ConfirmEmail(who, in.Code)
			if err != nil {
				return meEmailConfirmOut{}, err
			}
			if !ok {
				return meEmailConfirmOut{}, fmt.Errorf("that code is not valid any more: %w", ops.ErrNotFound)
			}
			// The address being LEFT is told, transactionally, so a
			// change nobody recognises does not go unnoticed simply
			// because the old inbox is still the one somebody reads.
			if before.Email != "" && before.Email != addr {
				d.mailTo(ctx, before.Email, "Your quarry email changed",
					"This address is no longer the one quarry uses for the account "+who+".\n\n"+
						"If you did not make this change, contact your forge's administrator.",
					"<p>This address is no longer the one quarry uses for the account <strong>"+
						htmlpkg.EscapeString(who)+"</strong>.</p><p>If you did not make this change, "+
						"contact your forge's administrator.</p>")
			}
			return meEmailConfirmOut{Email: addr}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[meEmailClearIn, struct{}]{
		Name:  "me.email.clear",
		Doc:   "Clear the signed-in account's email — confirmed and pending both.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/email/clear",
		Run: func(ctx context.Context, in meEmailClearIn) (struct{}, error) {
			who := ops.User(ctx)
			if who == "" {
				return struct{}{}, fmt.Errorf("clearing an email needs a signed-in account: %w", ops.ErrInvalid)
			}
			ae, err := d.accountEmail()
			if err != nil {
				return struct{}{}, err
			}
			return struct{}{}, ae.ClearEmail(who)
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[accountEmailSetIn, accountEmailSetOut]{
		Name:  "account.email.set",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc: "Set or correct an existing account's email as an administrator. Stored CONFIRMED at " +
			"once — an admin typing an address carries the same trust as the invite form — and the " +
			"account is told, at that address, that an admin set it.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/email",
		Run: func(ctx context.Context, in accountEmailSetIn) (accountEmailSetOut, error) {
			if !strings.Contains(in.Email, "@") {
				return accountEmailSetOut{}, fmt.Errorf("email must be an address: %w", ops.ErrInvalid)
			}
			ae, err := d.accountEmail()
			if err != nil {
				return accountEmailSetOut{}, err
			}
			if _, found := ae.AccountByName(in.Name); !found {
				return accountEmailSetOut{}, fmt.Errorf("no such account %q: %w", in.Name, ops.ErrNotFound)
			}
			if err := ae.SetEmail(in.Name, in.Email); err != nil {
				return accountEmailSetOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			from := ops.User(ctx)
			d.mailTo(ctx, in.Email, "Your quarry email",
				from+" set this address for your quarry account.",
				"<p>"+htmlpkg.EscapeString(from)+" set this address for your quarry account.</p>")
			return accountEmailSetOut{Name: in.Name, Email: in.Email}, nil
		},
	})
}