// email.go is the self-serve half of an account's reachability: the
// address a person typed and PROVED receipt of, as opposed to the one
// account.go's email column already carries when an admin or an
// invite asserts it directly (that trust is the same trust as setting
// somebody's password for them). Confirmation exists because a typo,
// or someone else's inbox, must not quietly become where quarry's
// account mail — including the access-change mail transactional.go
// sends — goes from that moment on.
package oltp
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// EmailTTL is shorter than InviteTTL: a confirmation link is acted on
// at once or not at all, and a day is generous for "I'll get to it"
// without leaving a stale link that changes an address live for a week.
const EmailTTL = 24 * time.Hour
// emailMigrations: the address typed but not yet confirmed. Separate
// from account.email, which is what mail actually goes to — a
// half-finished change must not overwrite the working address before
// the new one is proven.
var emailMigrations = []string{
`ALTER TABLE account ADD COLUMN email_pending TEXT NOT NULL DEFAULT ''`,
}
const emailSchema = `
CREATE TABLE IF NOT EXISTS email_token (
token TEXT PRIMARY KEY, -- SHA-256 of the link's token, never the token
account TEXT NOT NULL,
address TEXT NOT NULL,
created TEXT NOT NULL,
expires TEXT NOT NULL,
used TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS email_token_by_account ON email_token (account);`
// SetPendingEmail records the address an account wants to confirm and
// mints the one-time token its link carries. Calling it again before
// the first token is used replaces both the pending address and the
// token — the same "latest wins" rule InviteAccount uses for a
// re-invite, and for the same reason: a lost email is the common case.
func (d *DB) SetPendingEmail(name, address string) (token string, err error) {
if address == "" {
return "", errors.New("oltp: empty email address")
}
now := time.Now().UTC()
if _, err := d.db.Exec(`UPDATE account SET email_pending = ? WHERE name = ?`, address, name); err != nil {
return "", err
}
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
token = hex.EncodeToString(buf[:])
_, err = d.db.Exec(`INSERT INTO email_token (token, account, address, created, expires) VALUES (?, ?, ?, ?, ?)`,
hashToken(token), name, address, now.Format(time.RFC3339), now.Add(EmailTTL).Format(time.RFC3339))
return token, err
}
// EmailTokenAccount names the account a live confirmation token
// belongs to, WITHOUT spending it — InviteFor's role for invites, and
// what lets a caller check ownership (this token is mine) before
// ConfirmEmail acts, rather than after.
func (d *DB) EmailTokenAccount(token string) (account string, ok bool) {
var used, expires string
if err := d.db.QueryRow(`SELECT account, used, expires FROM email_token WHERE token = ?`,
hashToken(token)).Scan(&account, &used, &expires); err != nil {
return "", false
}
exp, err := time.Parse(time.RFC3339, expires)
if used != "" || err != nil || time.Now().UTC().After(exp) {
return "", false
}
return account, true
}
// ConfirmEmail promotes a live token's pending address to confirmed
// and spends the token. The pending column is cleared either way this
// lands: once an address is confirmed, there is nothing left pending.
func (d *DB) ConfirmEmail(token string) (account, address string, ok bool, err error) {
var used, expires string
if err := d.db.QueryRow(`SELECT account, address, used, expires FROM email_token WHERE token = ?`,
hashToken(token)).Scan(&account, &address, &used, &expires); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
return "", "", false, err
}
exp, perr := time.Parse(time.RFC3339, expires)
if used != "" || perr != nil || time.Now().UTC().After(exp) {
return "", "", false, nil
}
if _, err := d.db.Exec(`UPDATE account SET email = ?, email_pending = '' WHERE name = ?`, address, account); err != nil {
return "", "", false, err
}
if _, err := d.db.Exec(`UPDATE email_token SET used = ? WHERE token = ?`,
time.Now().UTC().Format(time.RFC3339), hashToken(token)); err != nil {
return "", "", false, err
}
return account, address, true, nil
}
// ClearEmail blanks both the confirmed and pending address. Live
// tokens are spent rather than left alone: a confirm arriving after a
// clear must not resurrect the address that was just removed.
func (d *DB) ClearEmail(name string) error {
if _, err := d.db.Exec(`UPDATE account SET email = '', email_pending = '' WHERE name = ?`, name); err != nil {
return err
}
_, err := d.db.Exec(`UPDATE email_token SET used = ? WHERE account = ? AND used = ''`,
time.Now().UTC().Format(time.RFC3339), name)
return err
}