---
topic: email-blocklist
title: Replacing the e-mail blocklist
summary: temailblacklist via EmailBlocklistController — one POST truncates the whole list and rebuilds it, and the two consumers disagree about wildcards.
appliesTo:
  - email.blocklist.replace
derivedFrom:
  - includes/src/Router/Controller/Backend/EmailBlocklistController.php:31-45
  - includes/src/Router/Controller/Backend/AbstractBackendController.php:287-321
  - includes/src/Backend/Settings/Sections/Base.php:175-232
  - includes/src/Settings/Option/Blocklist.php:9-22
  - includes/src/SimpleMail.php:354-404
  - includes/src/Mail/Validator/MailValidator.php:79-99
  - admin/templates/bootstrap/emailblacklist.tpl:4-18
  - includes/src/Router/Route.php:57
---

## Read this first

`email.blocklist.replace` **replaces the entire blocklist**. `EmailBlocklistController` runs
`TRUNCATE temailblacklist` and then inserts the addresses it was posted (`:37-43`). There is no add,
no update and no delete — the screen has exactly one operation and it is "the list is now exactly
this". If you send one address, the shop is left with one address.

So the sequence is always: read the current list, decide the new one **including everything you want
to keep**, send that.

```
run_sql_read "SELECT cEmail FROM temailblacklist ORDER BY cEmail"
invoke_admin_action id=email.blocklist.replace confirm=true
                    args={cEmail: "a@example.com;b@example.com", blacklist_benutzen: "Y"}
run_sql_read "SELECT cEmail FROM temailblacklist ORDER BY cEmail"
```

## Domain model

Three tables, and only the first is written here.

- **`temailblacklist`** — the list itself. ONE column, `cEmail`, and it is the primary key
  (`build/components/vue-installer/initial_schema.sql:1312-1315`). There is no surrogate id, which is why the action declares no
  `idResolution`: neither `lastInsertId` nor `maxIdDelta` has anything to resolve. It also means a
  repeated address in your input collapses silently — `NiceDB::insertRow()` swallows the
  duplicate-key `PDOException` and returns 0 (`NiceDB.php:250-255`).
- **`teinstellungen`**, section `CONF_EMAILBLACKLIST` = 111 — holds `blacklist_benutzen`, the master
  switch. Written by the same POST (see below).
- **`temailblacklistblock`** — read-only from here. The mail path appends a row (address +
  `dLetzterBlock`) every time a send is actually blocked (`SimpleMail.php:381`, `:396`,
  `MailValidator.php:91`). This is where you check whether a blocklist entry is doing anything. No
  catalogued action writes it, and the entry does not journal it.

## One POST, two writes

`emailblacklist.tpl` puts **two** hidden constants in the same form (`:6-7`), so a single submit runs
both branches of the controller:

1. `einstellungen=1` → `saveAdminSectionSettings(CONF_EMAILBLACKLIST, $_POST)` (`:32`) — writes
   `teinstellungen` and flushes the `CACHING_GROUP_OPTION` cache tag.
2. `emailblacklist=1` → TRUNCATE + re-insert (`:34-45`).

Both are catalog constants, so a caller cannot fire one without the other. What a caller *can* do is
omit `blacklist_benutzen`: `Base::update()` skips any item that is not in the post
(`Base.php:186-190`), so the stored value is left alone. Note that the cache flush happens anyway —
`flushTags()` is called unconditionally at the end of `update()` (`Base.php:229`), whether anything
was written or not.

## Why there is no dry run

`dryRunnable: false`, and it is not a policy choice. `TRUNCATE` is DDL: MariaDB implicitly commits
the surrounding transaction before executing it. Wrapping this action in `beginTransaction()` would
buy nothing — the rows would already be gone — and the subsequent `rollback()` would fail outright.
So the entry runs un-wrapped and says so (`transactional: false`), and the only rehearsal available
is reading `temailblacklist` before you call.

The same fact is why `undo.cannotUndo` leads with it: the invocation cannot restore what it
truncated. The `journal.scope` is the whole table with **no** `by:` — the affected set is "all of
it", and no argument narrows it.

## The empty guard does not guard

```php
$addresses = \explode(';', Text::filterXSS(Request::pString('cEmail')));
if (\count($addresses) > 0) {
    $this->db->query('TRUNCATE temailblacklist');
```

`explode(';', '')` returns `['']` — a one-element array. `count()` is therefore **never** 0, and a
POST with an empty or entirely absent `cEmail` truncates the table and inserts nothing: the blocklist
is silently wiped. The insert loop then skips the empty part (`:39-41`), so nothing replaces it.

This is why the catalog marks `cEmail` `required: true` even though the shop does not enforce it, and
why the entry is `Destructive` rather than `Reversible`.

## Verifying, when nothing is reported

The blocklist branch alerts **nothing** — not on success, not on failure. The only feedback anywhere
on this screen comes from the settings half (`AbstractBackendController.php:291`, `:313`, `:318`),
and that is `AlertService`, i.e. session-scoped and absent from the response body.

The entry's `verify:` is therefore a literal count, `SELECT COUNT(*) AS n FROM temailblacklist` with
`minimum: 1`. It is deliberately not `WHERE cEmail = {{args.cEmail}}`: `cEmail` is a `;`-joined list,
never a row value, so that clause would be bindable and wrong. The count works because the TRUNCATE
came first — a non-zero count after the call can only have come from this call, so it separates "the
replace ran and wrote something" from "nothing happened".

## The two consumers disagree about wildcards

This is the difference between a blocklist that works and one that silently does not.

- `SimpleMail::checkBlacklist()` (`:354-404`) — lower-cases the candidate address, then walks every
  row. A `cEmail` containing `*` is turned into a regex (`*` → `[a-z0-9\-\_\.\@\+]*`) and matched
  against the **whole** address, with a length check so it is an anchored match (`:368-371`).
  So `*@spam.example` works here.
- `MailValidator::isBlacklisted()` (`:79-99`) — a plain `db->select('temailblacklist', 'cEmail',
  $email)` (`:84`). Exact match only. A `*` row is inert on this path; it can only ever match an
  address that literally contains an asterisk.

Both return `false` immediately unless the switch is on (`SimpleMail.php:363` via
`Settings::boolValue(Blocklist::DO_USE)`, `MailValidator.php:81` via
`config['emailblacklist']['blacklist_benutzen'] !== 'Y'`). A list stored with the switch at `'N'` —
the shop's default — blocks nothing at all.

Practical consequence: **wildcard entries are only partially effective**. Write the exact addresses
you actually want blocked, and treat a `*` row as a best-effort extra rather than as coverage.

## Pitfalls

1. **CSRF asymmetry.** The settings half checks the token inside `saveAdminSectionSettings()` and
   merely returns early with an alert when it fails (`AbstractBackendController.php:289-294`). The
   blocklist half checks `Form::validateToken()` itself, in the same `if` as its dispatch key
   (`:34`). One failed token can therefore skip the settings write and still rebuild the list. (The
   invoker mints a valid token for both — `BackendActionInvoker.php:99`, `:104-107` — so this is a
   fact about the screen, not a hazard of the tool.)
2. **The route is spelled differently from everything else.** `Route::EMAILBLOCKLIST =
   'emailblocklist'` (`Route.php:57`, wired in `Collection.php:57`), while the table, the template,
   the permission (`SETTINGS_EMAIL_BLACKLIST_VIEW`) and the settings section are all `blacklist`.
   The catalog entry carries the correct one; do not "fix" it.
3. **Sending only new addresses.** The most likely real-world error is treating this as "add to the
   blocklist". It is not, and the shop will not warn you.

## What you cannot do here

**You cannot add or remove one address.** Compose the full list yourself.

**You cannot dry-run it** (see above).

**You cannot clear the block history.** `temailblacklistblock` has no delete path in the backend at
all; it is written by the mail path and read by this screen (`:47-52`, capped at the 100 most recent).

**There is no IO endpoint.** `EmailBlocklistController` has no `getIO*` method and nothing in
`IOController` touches this table — the form POST is the whole API.
