// 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"
"html/template"
"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) accountMail {
return accountMail{
Kind: kindEmailCode,
Subject: "Your quarry email confirmation code",
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.",
Context: "Confirm this email address",
Body: template.HTML("<p style=\"margin:0 0 12px;\">Your quarry email confirmation code is</p>" +
"<p style=\"margin:0 0 12px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:26px;font-weight:700;letter-spacing:0.12em;\">" + htmlpkg.EscapeString(code) + "</p>" +
"<p style=\"margin:0 0 12px;color:#665c54;\">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>"),
Why: "You're getting this because this address was given for a quarry account.",
}
}
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)
}
sent := d.mailTo(ctx, in.Email, codeMail(code))
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, accountMail{
Kind: kindEmailChanged,
Subject: "Your quarry email changed",
Text: "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.",
Body: template.HTML("<p style=\"margin:0 0 12px;\">This address is no longer the one quarry uses for the account <strong>" +
htmlpkg.EscapeString(who) + "</strong>.</p><p style=\"margin:0 0 12px;\">If you did not make this change, " +
"contact your forge's administrator.</p>"),
Why: "You're getting this as a security notice: this was the address on the account.",
})
}
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, accountMail{
Kind: kindEmailSet,
Subject: "Your quarry email",
Text: from + " set this address for your quarry account.",
Body: template.HTML("<p style=\"margin:0 0 12px;\">" + htmlpkg.EscapeString(from) + " set this address for your quarry account.</p>"),
Open: "Open your account", URL: d.link("/me"),
Why: "You're getting this because this address was set on a quarry account.",
})
return accountEmailSetOut{Name: in.Name, Email: in.Email}, nil
},
})
}