diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go
index 1a597ed..08f3b32 100644
--- a/backend/internal/account/link.go
+++ b/backend/internal/account/link.go
@@ -2,6 +2,7 @@ package account
import (
"context"
+ "database/sql"
"errors"
"fmt"
"time"
@@ -16,6 +17,52 @@ import (
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
+// ErrLastIdentity is returned when removing an identity would leave the account with
+// none, making it unreachable after logout. The admin email-erase refuses it.
+var ErrLastIdentity = errors.New("account: cannot remove the last identity")
+
+// RemoveEmailIdentity deletes the account's email identity and any pending confirmations
+// for it, freeing the address for reuse. It refuses when the email is the account's only
+// identity (ErrLastIdentity) — that would leave the account unreachable — and returns
+// ErrNotFound when the account has no email identity. It backs the admin console's
+// "erase email" action.
+func (s *Store) RemoveEmailIdentity(ctx context.Context, accountID uuid.UUID) error {
+ ids, err := s.Identities(ctx, accountID)
+ if err != nil {
+ return err
+ }
+ hasEmail, others := false, 0
+ for _, id := range ids {
+ if id.Kind == KindEmail {
+ hasEmail = true
+ } else {
+ others++
+ }
+ }
+ if !hasEmail {
+ return ErrNotFound
+ }
+ if others == 0 {
+ return ErrLastIdentity
+ }
+ return withTx(ctx, s.db, func(tx *sql.Tx) error {
+ delID := table.Identities.DELETE().WHERE(
+ table.Identities.AccountID.EQ(postgres.UUID(accountID)).
+ AND(table.Identities.Kind.EQ(postgres.String(KindEmail))),
+ )
+ if _, err := delID.ExecContext(ctx, tx); err != nil {
+ return fmt.Errorf("account: delete email identity %s: %w", accountID, err)
+ }
+ delConf := table.EmailConfirmations.DELETE().WHERE(
+ table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)),
+ )
+ if _, err := delConf.ExecContext(ctx, tx); err != nil {
+ return fmt.Errorf("account: delete email confirmations %s: %w", accountID, err)
+ }
+ return nil
+ })
+}
+
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
diff --git a/backend/internal/account/userlist.go b/backend/internal/account/userlist.go
index f2c39a3..9103c9f 100644
--- a/backend/internal/account/userlist.go
+++ b/backend/internal/account/userlist.go
@@ -28,11 +28,13 @@ type UserListItem struct {
// UserFilter narrows the admin user list: Robots selects robot accounts (otherwise the
// non-robot "people"); NameMask and ExternalIDMask are glob masks ('*' = any run, '?' =
// one char) matched case-insensitively against the display name / any identity's external
-// id. An empty mask means no filter on that field.
+// id; EmailExact is a strict (exact) match against an account's email identity. An empty
+// value means no filter on that field.
type UserFilter struct {
Robots bool
NameMask string
ExternalIDMask string
+ EmailExact string
}
// robotExists is the correlated subquery testing whether account a is a robot.
@@ -63,6 +65,10 @@ func userListWhere(f UserFilter) (string, []any) {
args = append(args, ext)
where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.external_id ILIKE $%d ESCAPE '\')`, len(args))
}
+ if email := strings.ToLower(strings.TrimSpace(f.EmailExact)); email != "" {
+ args = append(args, email)
+ where += fmt.Sprintf(` AND EXISTS (SELECT 1 FROM backend.identities i WHERE i.account_id = a.account_id AND i.kind = 'email' AND i.external_id = $%d)`, len(args))
+ }
return where, args
}
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
index 9be7009..58194f6 100644
--- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -101,6 +101,11 @@
{{else}}