# Invite Autocomplete — Email Exposure (Finding H)

**Date:** July 3, 2026
**Status:** ✅ IMPLEMENTED July 5, 2026 (Andrew's decision: recommended format,
first 2 + `***` + last 1 + full domain). Uncommitted — needs manual test + commit.
Both invite pages (season `/my-group/{reg}` and tournament team) share
`GroupController::manage()` / `userSearch()` / `group-management.js`, so one fix
covers both. Beyond the proposal, the Pending Invitations table now also shows
the masked address when the invitee was picked from the autocomplete (it would
otherwise have re-exposed the raw email after sending an invite); typed-in
addresses still display in full. `escapeLike` cleanup done; rate limit left at
30/60s. Files: `GroupController.php` (maskEmail helper, userSearch response,
manage() display map, DI of database), `js/group-management.js`,
`ccsoccer-group-manage.html.twig`, `ccsoccer.module`. Deploy: `drush cr` only.
**Leaning:** Mask the email (see recommended format below).
**Severity:** Medium — modest privacy issue, but worth closing **before** public
self-registration opens and the whole user base is emailed to sign up.

---

## The concern (precisely)

On the **Invite a Player** page (`/my-group/{registration}`), the autocomplete
calls `GET /api/ccsoccer/user-search?q=...`. For a partial term like `test`, it
returns a list of **other** league members with their **full email addresses**
(name + email + registered/not-registered badge).

Because anyone logged in can call this endpoint (30 searches/60s), a member — or,
once self-registration is open, any outsider who creates an account — can type
common strings and page through the results to **harvest the roster's names and
email addresses** into a spam/phishing list. That's the risk.

### What is NOT a concern
When a manager types a **full email that isn't in the system**
(e.g. `testuser400@example.com` → "New player - will send invite to create
account"), the page just echoes back the address the manager typed themselves.
Nothing is revealed that they didn't already know. This path needs no change.

### Why "just hide the email" isn't quite right
The email is a real **disambiguator**. In the screenshot, three different people
all display as "test user"; with only the name shown, the manager can't tell which
one is the person they mean. In a real league you'll have shared names (two Mike
Johnsons). So we want to keep *enough* of the email to recognize the right person,
without handing over a clean, harvestable address. That points to **masking**
rather than removing.

---

## Recommendation: mask the email (and stop sending the raw email)

Two parts. The second is the part that actually stops harvesting.

### 1. Display a masked email
Keep the domain visible (helps disambiguation, low value on its own), mask the
local part, and use a **fixed** number of asterisks so the address length isn't
revealed.

**Recommended format — first 2 chars + `***` + last 1 char + full domain:**

| Real address | Masked (shown in dropdown) |
|--------------|----------------------------|
| `testuser11@example.com`  | `te***1@example.com` |
| `testuser659@example.com` | `te***9@example.com` |
| `testuser724@example.com` | `te***4@example.com` |
| `mjohnson@gmail.com`       | `mj***n@gmail.com` |
| `sarah.lee@yahoo.com`      | `sa***e@yahoo.com` |

Keeping the first two and last one characters lets a member recognize their own
contact ("yes, that's the gmail one ending in n") and tells near-duplicates apart,
while the middle is unrecoverable. Short local parts get masked harder:

- local length ≤ 2 → `a***@domain` (first char only)
- local length 3–5 → `ab***@domain` (first two, no last char)
- local length ≥ 6 → `ab***z@domain` (first two + last one)

**Alternatives if you and Caleb want to tune the strictness:**

- **Stricter (less disambiguation):** drop the last char → `te***@example.com`.
  Note: with the current all-`testuser###` data this makes several rows identical;
  real-world local parts vary more, so collisions are rarer in production.
- **Strictest (also mask domain):** `te***@e***.com`. Worth it only if some members
  use small custom domains where the domain itself identifies them; costs
  disambiguation for everyone else.

### 2. Stop sending the raw email in the response (the real fix)
Masking in the browser alone would be **security theater** — if the endpoint still
returns the full address in the JSON, it's trivially read from the network tab. So
the endpoint must not send the raw email for existing users at all.

**Good news — this is clean, because inviting already runs off the user ID:**
`GroupController::invite()` (lines ~787–793) reads `invitee_uid` from the POST and
resolves the real email server-side:

```php
$invitee_uid = $request->request->get('invitee_uid');
$invitee_email = $request->request->get('invitee_email');
if ($invitee_uid) {
  $invitee = $this->entityTypeManager->getStorage('user')->load($invitee_uid);
  if ($invitee) {
    $invitee_email = $invitee->getEmail(); // real email resolved from uid
  }
}
```

So for existing users the browser only needs the `uid` and the masked string for
display — the actual send still works. The posted `invitee_email` is only used for
the brand-new-player case (uid = 0), where the manager typed the address anyway.

---

## Exact touch points

1. **`src/Controller/GroupController.php`, `userSearch()` (~lines 687–693).**
   For existing users, replace `'email' => $user->getEmail()` with
   `'masked_email' => $this->maskEmail($user->getEmail())` and **do not** include
   the raw email. Add a small private `maskEmail()` helper implementing the rule
   above. Leave the new-player branch (~636–642, uid = 0) unchanged — that address
   was typed by the manager.

2. **`js/group-management.js` (~lines 140–151).**
   Display `user.masked_email` instead of `user.email`. On select, pass
   `user.uid` (and name); the hidden `invitee_email` field can be left empty for
   existing users since the server resolves it from uid. The new-player branch
   (~108–122) is unchanged.

3. **No change** to `invite()` — it already prefers uid over the posted email.

### Secondary cleanups worth doing in the same pass
- **Rate limit:** currently 30 searches/60s (`userSearch`, line ~597). Consider
  tightening (e.g. 15–20/60s) to further blunt bulk scraping. Low user impact.
- **`escapeLike` consistency (minor, not SQLi):** the LIKE conditions at lines
  625–627 use the raw `$term`; the other three search surfaces wrap it in
  `escapeLike()`. Not an injection (values are parameterized), but `%`/`_` typed by
  a user behave as wildcards here. Wrap for consistency:
  `'%' . $this->database->escapeLike($term) . '%'`.

---

## What this does and doesn't cover
- **Covers:** bulk harvesting of members' contactable **email addresses** via the
  autocomplete — the main risk once the site is public.
- **Does not hide names.** Member **names** remain visible to logged-in users; the
  invite feature needs them, and a name alone isn't a spam target. Accepted.
- **Registered/Not-registered badge** still tells a searcher whether a matched
  person is registered for this season. Minor, and useful (don't invite someone
  already in). Accepted.

## Effort
Roughly 1–2 hours: the `maskEmail()` helper + response change, the JS display
tweak, and a manual test that inviting an existing user and a brand-new email both
still work.
