> ## Documentation Index
> Fetch the complete documentation index at: https://docs.frankenpress.com/llms.txt
> Use this file to discover all available pages before exploring further.

# mu-plugin

> Slim WordPress must-use plugin: S3 uploads, Souin invalidation, Site Health, SMTP

[`mu-plugin`](https://github.com/frankenpress/mu-plugin) is the
WordPress-side glue for the FrankenPress stack. Four request-path
components plus one off-request-path WP-CLI surface, all platform-
housekeeping — anything else (URL fixers, object cache, metrics, WC
log handlers) is **optional** and is the consumer site's responsibility.

| Component                                     | What it does                                                                                                                                                                                                                                                                                                                                                                        |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [**S3UploadsBootstrap**](#s3uploadsbootstrap) | Configures [`humanmade/s3-uploads`](https://github.com/humanmade/S3-Uploads) from `FP_S3_*` env vars. **Refuses uploads** when S3 isn't fully configured (no silent local-disk fallback).                                                                                                                                                                                           |
| [**SouinInvalidator**](#souininvalidator)     | `DEL`s Souin's Redis cache entries directly on `save_post`, `clean_post_cache`, `switch_theme`, etc. — bypasses cache-handler's broken HTTP invalidation.                                                                                                                                                                                                                           |
| [**SiteHealth**](#sitehealth)                 | Suppresses the Site Health tests whose failure is intentional under the immutable-image lockdown (`background_updates`, FS-write probes, `plugin_theme_auto_updates`) and adds a passing FrankenPress-branded test that explains why. Also adds an SMTP-reachability test when SMTPMailer is configured.                                                                            |
| [**SMTPMailer**](#smtpmailer)                 | Wires the global PHPMailer to send via SMTP from `FP_SMTP_*` env vars. The runtime image ships no MTA, so without this every `wp_mail()` call fails silently. Transport-agnostic (Postmark, SendGrid, Mailgun, AWS SES, in-cluster relay). Opt-in: no-op when `FP_SMTP_HOST` is unset.                                                                                              |
| [**SnapshotExporter**](#snapshotexporter)     | Captures site state (`fp.snapshot/v5`) and uploads to a per-tenant S3 snapshot bucket on a daily wp-cron event at site-local midnight + an admin button under Tools → Snapshot Export. Drives the prod-side half of [`fp pull`](/designer-flow-fp-pull). Opt-in: no-op when `FP_SNAPSHOT_BUCKET` is unset.                                                                          |
| [**`wp fp` CLI**](#wp-fp-cli) *(WP-CLI only)* | Registers `wp fp snapshot` + `wp fp apply` subcommands for the [designer flow](/designer-flow). Adapter-scoped, WXR-based, additive. Schema `fp.snapshot/v5`. Apply-time `AuthorRemapper` rewrites imported `post_author` to the local admin (gated on `FP_APPLY_REMAP_AUTHORS`, set transiently by the apply pipeline). Only loads under `WP_CLI` — zero overhead on web requests. |

Composer name: `frankenpress/mu-plugin`. Latest release: `v0.15.1`.

## S3UploadsBootstrap

Maps `FP_S3_*` env vars to humanmade/s3-uploads' `S3_UPLOADS_*` constants
and auto-loads the s3-uploads plugin from composer's vendor / plugins
dir. In-cluster, **refuses media uploads** via `wp_handle_upload_prefilter`
when required env vars are missing. Out-of-cluster (no
`KUBERNETES_SERVICE_HOST`), the bootstrap auto-skips entirely and
WordPress writes to local disk — see `FP_S3_DISABLED` below.

### Why refuse rather than fall back?

In a containerized deploy, local disk is **ephemeral** (gone on pod
restart) and **inconsistent across replicas** (write to pod A, read from
pod B → 404). Silently writing uploads to local disk would produce
broken behaviour that surfaces minutes-to-hours later. A hard upload
failure surfaces the misconfiguration immediately.

Out-of-cluster (docker-compose, bare local) those concerns don't
apply: there's one container, the disk persists for the lifetime of
the volume, and admin install flows (block-plugin installers, theme
zip uploads) need a real writable path because the `s3://` stream
wrapper doesn't support every `ZipArchive` / `unzip_file` operation.
The bootstrap auto-skips out-of-cluster for that reason. Force it
back on with `FP_S3_DISABLED=0` to exercise the S3 path against MinIO.

### Env vars

| Var                | Default                                 | Required                                                                                                                                                                                                                                                                                                                           |
| ------------------ | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FP_S3_BUCKET`     | —                                       | ✓                                                                                                                                                                                                                                                                                                                                  |
| `FP_S3_KEY`        | —                                       | ✓                                                                                                                                                                                                                                                                                                                                  |
| `FP_S3_SECRET`     | —                                       | ✓                                                                                                                                                                                                                                                                                                                                  |
| `FP_S3_REGION`     | `us-east-1`                             |                                                                                                                                                                                                                                                                                                                                    |
| `FP_S3_BUCKET_URL` | —                                       | optional CDN URL — auto-sets `WP_CONTENT_URL` if undefined                                                                                                                                                                                                                                                                         |
| `FP_S3_ENDPOINT`   | —                                       | optional, for non-AWS S3-compatible (MinIO, R2, GCS XML)                                                                                                                                                                                                                                                                           |
| `FP_S3_OBJECT_ACL` | (empty — no ACL)                        | optional S3 object ACL. Leave unset for buckets with Object Ownership = "Bucket owner enforced" (the AWS default since April 2023, ACLs disabled). Set to `public-read` / `private` / `authenticated-read` only for ACL-enabled buckets. See [troubleshooting → 0-byte uploads](/operations/troubleshooting#0-byte-uploads-to-s3). |
| `FP_S3_DISABLED`   | auto: on in-cluster, off out-of-cluster | tri-state. Truthy (`1`/`true`/`yes`/`on`) → off. Falsy (`0`/`false`/`no`/`off`) → force on. Unset → default gates on `KUBERNETES_SERVICE_HOST` (kubelet-injected on every pod): production stays on, local dev skips the `s3://` stream wrapper so admin install flows hit local disk. Force on locally with `FP_S3_DISABLED=0`.   |

### Endpoint filter (v0.1.1)

humanmade/s3-uploads documents `S3_UPLOADS_ENDPOINT` but doesn't apply
it to the AWS SDK by itself — you have to register a filter. v0.1.1 of
this plugin registers `s3_uploads_s3_client_params` automatically when
the endpoint constant is defined, with `use_path_style_endpoint: true`
(MinIO and most S3-compatibles don't support virtual-host bucket
addressing).

## SouinInvalidator

Connects directly to Redis and `DEL`s Souin's cache entries on
WordPress lifecycle events.

### Hooks

The invalidator covers every WP write event whose output is templated
into cached pages. Bounded changes invalidate the affected URL; changes
with site-wide blast radius flush the entire HTTP cache. TTL expiry is
the safety net for anything missed.

| Category                 | Hooks                                                                                                                                 | Action                    |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
| Post lifecycle           | `save_post`, `wp_trash_post`, `before_delete_post`, `deleted_post`, `clean_post_cache`                                                | DEL post URL + tag + home |
| Global-impact post types | `save_post` for `wp_navigation`, `wp_block`, `wp_template`, `wp_template_part`, `wp_global_styles`                                    | `invalidate_all`          |
| Comments                 | `comment_post`, `transition_comment_status`                                                                                           | DEL parent post           |
| Term lifecycle           | `created_term`, `edited_term`, `delete_term`                                                                                          | `invalidate_all`          |
| User lifecycle           | `profile_update`, `user_register`, `deleted_user`                                                                                     | DEL author archive + home |
| Site-wide                | `switch_theme`, `permalink_structure_changed`, `wp_update_nav_menu`, `customize_save_after`, `activated_plugin`, `deactivated_plugin` | `invalidate_all`          |
| Options                  | `updated_option` for `blogname`, `home`, `siteurl`, `permalink_structure`, `sidebars_widgets`, `widget_*`                             | `invalidate_all`          |

### Env vars

| Var                       | Default | Purpose                                                         |
| ------------------------- | ------- | --------------------------------------------------------------- |
| `FP_SOUIN_REDIS_HOST`     | `redis` | Redis hostname                                                  |
| `FP_SOUIN_REDIS_PORT`     | `6379`  | Redis port                                                      |
| `FP_SOUIN_REDIS_PASSWORD` | (empty) | Redis AUTH password                                             |
| `FP_SOUIN_REDIS_DB`       | `0`     | Logical database                                                |
| `FP_SOUIN_REDIS_TIMEOUT`  | `1.0`   | Connect timeout (seconds)                                       |
| `FP_SOUIN_DISABLED`       | `false` | Truthy → no-op the invalidator (cache then expires only by TTL) |

### Why direct Redis DEL?

Souin's documented HTTP invalidation APIs (`PURGE`, POST-CRUD, the
`/api.souin/*` admin endpoint) are broken in cache-handler v0.16.0:

* `PURGE` returns 200 but **caches the PURGE response itself** instead of invalidating the GET.
* `POST` likewise — caches the POST response.
* The admin endpoint at `/api.souin/...` returns 404 even when the API basepath is configured.

Direct Redis manipulation is sub-millisecond, has no parser to break,
and works against any Redis-compatible service (including DragonflyDB).
Full investigation log:
[`runtime/PHASE-0.md`](https://github.com/frankenpress/runtime/blob/main/PHASE-0.md).

### Failure mode

If `ext-redis` isn't loaded, or the connection fails, or
`FP_SOUIN_DISABLED` is set, the invalidator is a **silent no-op**.
Errors are logged via `error_log` but never raised — a broken cache
layer must not break WordPress itself.

## SiteHealth

Tweaks WordPress's Site Health screen so it reports signal, not noise,
on a FrankenPress site. Removes the four lockdown-related tests
(`background_updates`, `update_temp_backup_writable`,
`available_updates_disk_space`, `plugin_theme_auto_updates`) — their
failure is correct behaviour under the immutable-image lockdown — and
adds a passing FrankenPress-branded test that explains the lockdown
to anyone reading the dashboard.

When `FP_SMTP_HOST` is set (i.e. SMTPMailer has been opted into), this
component also registers a `frankenpress_smtp_reachability` Site Health
test that does a 2-second TCP connect to the configured SMTP server.
Failure produces a `critical` red badge on the wp-admin Site Health
screen — the canonical "is my email actually working" check.

## SMTPMailer

Reads `FP_SMTP_*` environment variables and hooks `phpmailer_init` to
override the global PHPMailer instance with SMTP. Transport-agnostic —
the same env contract works for Postmark, SendGrid, Mailgun, AWS SES,
or any in-cluster relay (mailpit, mailhog, postfix-relay).

For end-to-end recipes, see [Operations → Email](/operations/email).

### Env vars

| Var                  | Default            | Purpose                                                                                                                                                                                |
| -------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FP_SMTP_HOST`       | (unset)            | SMTP hostname (e.g. `smtp.postmarkapp.com`). Component is a no-op when unset.                                                                                                          |
| `FP_SMTP_PORT`       | `587`              | TCP port                                                                                                                                                                               |
| `FP_SMTP_ENCRYPTION` | `tls`              | `tls` (STARTTLS), `ssl` (implicit TLS), `none` (local dev only)                                                                                                                        |
| `FP_SMTP_USERNAME`   | (unset)            | SMTP auth username                                                                                                                                                                     |
| `FP_SMTP_PASSWORD`   | (unset)            | SMTP auth password                                                                                                                                                                     |
| `FP_SMTP_FROM_EMAIL` | (WP `admin_email`) | `wp_mail_from` filter target                                                                                                                                                           |
| `FP_SMTP_FROM_NAME`  | (WP `blogname`)    | `wp_mail_from_name` filter target                                                                                                                                                      |
| `FP_SMTP_DISABLED`   | `false`            | Truthy → bootstrap is a no-op. **Local-dev escape hatch only.** The Helm chart never sets this; chart-side `smtp.enabled: false` covers the same need by simply not injecting the env. |

### Failure mode

If `FP_SMTP_HOST` is unset, the component is a silent no-op and
`wp_mail()` falls through to PHP's `mail()` — which fails on the
runtime image because no MTA is shipped. That's the intentional
default for sites that haven't opted into SMTP yet.

When the host is set but unreachable / auth fails, `wp_mail()` returns
`false` and the failure is logged via `error_log`. The component
**never retries, queues, or falls back to `mail()`** — surfacing the
failure beats hiding it. The SiteHealth `frankenpress_smtp_reachability`
test surfaces the same failure on the Site Health dashboard.

### Plugin coexistence

A site that composer-installs a competing SMTP plugin (WP Mail SMTP,
FluentSMTP, the official Postmark plugin) overrides SMTPMailer's
config — those plugins run as regular plugins after must-use plugins
and re-hook `phpmailer_init` at the same priority. Last-writer wins.
Correct behaviour: the user opted into the plugin explicitly; we don't
fight them.

## SnapshotExporter

Drives the prod-side half of [`fp pull`](/designer-flow-fp-pull).
Two entry points, one code path:

* **Daily wp-cron event** (`frankenpress_snapshot_export`) fires at
  site-local midnight (`wp_timezone()`). Picked up by the K8s wp-cron
  CronJob's `wp cron event run --due-now` invocation, so capture runs
  in a WP-CLI process where `\WP_CLI::runcommand` is available (the
  inner `wp export` needs it).
* **Admin button** under Tools → Snapshot Export schedules a one-shot
  event on the same hook. Shows "queued" — the actual capture runs
  in the next wp-cron tick (\~60s).

Capture reuses the existing `wp fp snapshot` machinery via a shared
`Cli\Snapshot\Factory`. Upload uses `Aws\S3\S3Client::putObject` per
file — the AWS SDK is already a transitive dep through humanmade/
s3-uploads, no composer change needed.

### Required env vars

```bash theme={null}
FP_SNAPSHOT_BUCKET   # gating + bucket name; component is dormant when unset
FP_SNAPSHOT_KEY      # AWS access key id (typically ESO-projected from ASM)
FP_SNAPSHOT_SECRET   # AWS secret access key
FP_SNAPSHOT_REGION   # optional; defaults to eu-west-2
```

### Wire format + cadence

Same `fp.snapshot/v5` envelope used by `wp fp snapshot`. PII allowlist
unchanged: posts/pages/attachments + FSE templates + allowlisted
options. **No** `wp_users` / `wp_comments` / `wp_usermeta`. Captured
attachments are `wp_posts` rows only — designers consume the actual
binaries from the production uploads bucket directly via
`humanmade/s3-uploads`.

The bucket is provisioned by `tg_frankenpress` (one per production
site, named `<site>-production-snapshots-<region>-<account>`,
**7-day lifecycle**). The service-user IAM policy is scoped to
`s3:PutObject` + `s3:ListBucket` only — no read, no delete. Worst
case under a pod compromise is junk in the bucket that the lifecycle
reaps.

The chart's `snapshotExport.*` values map directly to these env vars
([`/components/charts`](/components/charts)).

## wp fp CLI

Off the request path — loads only under `WP_CLI`. Zero overhead on
web requests. Provides the two subcommands the [designer
flow](/designer-flow) relies on:

```bash theme={null}
# Capture local state into web/imports/<slug>/
wp fp snapshot --slug=<slug> [--note='<text>'] [--output-dir=<path>]

# Apply a snapshot directory to the current site
wp fp apply --snapshot-dir=<path>
```

**Adapter-scoped, design-only, ID-stable.** The bundled `Fse`
adapter detects when the active theme is an FSE block theme
(`wp_is_block_theme()`) and declares scope:

* **Owned post types** (templates.json, **upsert** by slug):
  `wp_template`, `wp_template_part`, `wp_global_styles`,
  `wp_navigation`
* **Option keys** (options.json, upsert): `blogname`,
  `blogdescription`, `show_on_front`, `page_on_front`,
  `page_for_posts`, `permalink_structure`, `site_icon`,
  `site_logo`, `custom_logo`
* **Attachment refs** (attachments.json + binaries): attachments
  referenced by the logo options OR by inline `wp:image` block
  markup inside captured owned posts (e.g. an image block with
  `"id":42` referencing an attachment). Other uploads are skipped
  — they're treated as content.
* **Theme mods**: for the active stylesheet only

**Pages and posts are intentionally OUT of scope** — they're
content the editor publishes live, not design state the designer
ships. Anything else (WooCommerce orders, user accounts, comments,
custom CPTs not declared) is structurally inaccessible.

**Apply is upsert-by-key.** Owned posts upsert by
`post_name + post_type` so designer iteration on existing
templates propagates. Attachments upsert by `_wp_attached_file`
(the relative path under `uploads/`, which is stable across
environments). Options upsert by key. The captured-ID → local-ID
remap rewrites `site_logo` values + inline block-attr `"id":N`
references + `wp-image-N` CSS classes during apply so attachment
references land pointing at the right local post.

**Binaries ship in the snapshot.** Designer-asset attachments
travel as bundled files in `snapshot/uploads/<rel-path>/`. At
apply time, files are copied into `wp_get_upload_dir()['basedir']`
— with `S3UploadsBootstrap` active that's an `s3://` stream
wrapper write so files land directly in S3.

**Idempotent.** Apply stamps `fp_snapshot_applied_ref` and
`fp_snapshot_applied_sha256` options on success; subsequent
invocations with the same snapshot short-circuit (`Success:
apply skipped`).

Schema: `fp.snapshot/v4`. Earlier `v2` / `v3` snapshots are
rejected by the Restorer — re-capture under a `v0.11.0+` install
to migrate.

For the full workflow (Site Editor → snapshot → commit → deploy), see
[Designer flow](/designer-flow).

## Install

```bash theme={null}
composer require frankenpress/mu-plugin
```

[`site-template`](/components/site-template) ships this dep
preconfigured. The plugin lands at
`web/app/mu-plugins/mu-plugin/` and is auto-loaded by
[`roots/bedrock-autoloader`](https://github.com/roots/bedrock-autoloader)
via the `00-stack.php` loader.

## Local testing

The package ships PHPUnit unit tests using
[Brain Monkey](https://github.com/Brain-WP/BrainMonkey) (WP function
stubs) and Mockery (Redis client mocks). No real Redis or WP install
needed:

```bash theme={null}
composer install
composer test     # phpunit
composer lint     # phpcs (WordPress-Core ruleset)
composer stan     # phpstan level 6
composer ci       # all four checks (lint + stan + test + audit)
```
