// email.go wires the confirmed-address surface: a person setting or
// changing their own address (self-serve, proven by a one-time link,
// mirroring account.invite's shape exactly), 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 confirmation token.
SetPendingEmail(name, address string) (token string, err error)
// EmailTokenAccount names a live token's account without spending
// it, so a caller can check ownership before ConfirmEmail acts.
EmailTokenAccount(token string) (account string, ok bool)
// ConfirmEmail promotes a live token's address to confirmed and
// spends the token.
ConfirmEmail(token string) (account, 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
}
// confirmMail is the one piece of copy the confirmation link's email
// wears, shared by every place that mints one — today only
// me.email.set, but the wording belongs in one place regardless.
func confirmMail(link string) (text, html string) {
text = "Confirm this address for your quarry account:\n\n" + link +
"\n\nThe link works once and expires in a day. If you did not ask for this, ignore it."
html = "<p>Confirm this address for your quarry account.</p>" +
"<p><a href=\"" + htmlpkg.EscapeString(link) + "\">Confirm my email</a></p>" +
"<p style=\"color:#666\">The link works once and expires in a day. If you did not ask for this, ignore it.</p>"
return text, html
}
type meEmailSetIn struct {
Email string `json:"email" validate:"required,max=200"`
// Base is the forge's origin, for the confirmation link; the web
// passes its own, matching account.invite's Base field.
Base string `json:"base" validate:"max=200"`
}
type meEmailSetOut struct {
// Pending echoes the address a confirmation 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 {
Token string `json:"token" validate:"required,min=32,max=128"`
}
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 /me/notifications
// 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 one-time link " +
"goes to the NEW address, and the address quarry actually mails stays whatever was " +
"confirmed before (if anything) until that link is used.",
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
}
token, err := ae.SetPendingEmail(who, in.Email)
if err != nil {
return meEmailSetOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
}
link := d.base(in.Base) + "/me/email/confirm?t=" + token
text, html := confirmMail(link)
sent := d.mailTo(ctx, in.Email, "Confirm your quarry email", 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 one-time link. " +
"Refuses a token that is unknown, spent, expired, or belongs to a different account than " +
"the one asking — a confirmation is never redeemable by anyone but its own owner.",
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)
ae, err := d.accountEmail()
if err != nil {
return meEmailConfirmOut{}, err
}
// Ownership is checked BEFORE the token is spent: a token
// named in someone else's session must fail without
// burning the link its real owner still holds.
acct, ok := ae.EmailTokenAccount(in.Token)
if !ok || who == "" || acct != who {
return meEmailConfirmOut{}, fmt.Errorf("this confirmation link is not valid any more: %w", ops.ErrNotFound)
}
before, _ := ae.AccountByName(who)
_, addr, ok, err := ae.ConfirmEmail(in.Token)
if err != nil {
return meEmailConfirmOut{}, err
}
if !ok {
return meEmailConfirmOut{}, fmt.Errorf("this confirmation link 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",
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
},
})
}