@hostinger/mcp 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/gemini-extension.json +1 -1
  3. package/package.json +1 -1
  4. package/server.json +2 -2
  5. package/skills/headless/steps/02-refine/SKILL.md +7 -0
  6. package/skills/headless/steps/03-provision/wordpress/SKILL.md +63 -0
  7. package/skills/headless/steps/04-content-model/wordpress/SKILL.md +58 -5
  8. package/skills/headless/steps/04-content-model/wordpress/contact-form.md +227 -0
  9. package/skills/headless/steps/04-content-model/wordpress/custom-plugins.md +481 -0
  10. package/skills/headless/steps/06-frontend/SKILL.md +15 -0
  11. package/skills/headless/steps/07-validate/SKILL.md +13 -0
  12. package/skills/headless/steps/09-done/SKILL.md +5 -0
  13. package/src/core/skills.js +32 -18
  14. package/src/core/skills.ts +32 -18
  15. package/src/servers/agency-hosting.js +1 -1
  16. package/src/servers/agency-hosting.ts +1 -1
  17. package/src/servers/agent-readiness.js +1 -1
  18. package/src/servers/agent-readiness.ts +1 -1
  19. package/src/servers/all.js +1 -1
  20. package/src/servers/all.ts +1 -1
  21. package/src/servers/billing.js +1 -1
  22. package/src/servers/billing.ts +1 -1
  23. package/src/servers/dns.js +1 -1
  24. package/src/servers/dns.ts +1 -1
  25. package/src/servers/domains.js +1 -1
  26. package/src/servers/domains.ts +1 -1
  27. package/src/servers/ecommerce.js +1 -1
  28. package/src/servers/ecommerce.ts +1 -1
  29. package/src/servers/horizons.js +1 -1
  30. package/src/servers/horizons.ts +1 -1
  31. package/src/servers/hosting.js +1 -1
  32. package/src/servers/hosting.ts +1 -1
  33. package/src/servers/mail.js +1 -1
  34. package/src/servers/mail.ts +1 -1
  35. package/src/servers/reach.js +1 -1
  36. package/src/servers/reach.ts +1 -1
  37. package/src/servers/vps.js +1 -1
  38. package/src/servers/vps.ts +1 -1
  39. package/src/servers/wordpress.js +1 -1
  40. package/src/servers/wordpress.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ Generated at release time from the diff between the previous and current
4
4
  build. Tool changes are listed individually; everything else is summarised
5
5
  by area.
6
6
 
7
+ ## v3.5.0 — 2026-10-21
8
+
9
+ ### Also in this release
10
+
11
+ - Bundled agent skills: 8 files
12
+ - Other files: `src/core/skills.js`, `src/core/skills.ts`
13
+
14
+ ## v3.4.2 — 2026-10-21
15
+
16
+ No changes to the published artifact.
17
+
7
18
  ## v3.4.1 — 2026-10-21
8
19
 
9
20
  ### Operations
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hostinger-api-mcp",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "Hostinger API — manage domains, DNS, hosting, VPS, billing and more from Gemini CLI.",
5
5
  "mcpServers": {
6
6
  "hostinger": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostinger/mcp",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "mcpName": "io.github.hostinger/hostinger-api-mcp",
5
5
  "description": "MCP server for Hostinger API",
6
6
  "repository": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/hostinger/api-mcp-server",
7
7
  "source": "github"
8
8
  },
9
- "version": "3.4.1",
9
+ "version": "3.5.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "@hostinger/mcp",
14
- "version": "3.4.1",
14
+ "version": "3.5.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -109,6 +109,13 @@ If the user asks for something outside the scope (user accounts, member areas, b
109
109
  calendar backend, user-generated content), say plainly that the headless flow doesn't support it yet
110
110
  and record it under **Out of scope** — don't promise it.
111
111
 
112
+ > **A contact form *is* supported (WordPress backends).** If the user wants one, keep it in
113
+ > **Must-haves** and name it "Contact form". Provision installs **Contact Form 7** on `/cms` and the
114
+ > frontend wires a contact page whose form emails the site owner through WordPress's built-in
115
+ > `wp_mail()` — no third-party service and no API keys. It's **opt-in**: built only when the brief asks
116
+ > for it. (A form that needs accounts, payments or a booking calendar is still out of scope — this is a
117
+ > plain name/email/message enquiry form.)
118
+
112
119
  ### Build mode — ask once (this one *is* a user choice)
113
120
 
114
121
  The backend is your call, but **how the frontend gets built is the user's** — because most of the time
@@ -190,6 +190,46 @@ it once `provision.status == "completed"`. The authoritative data-layer referenc
190
190
  > user for payment credentials. Store currency and address (`woocommerce_*` options) likewise aren't
191
191
  > settable headlessly — they follow the install default and the owner can change them in wp-admin.
192
192
 
193
+ ## Step 5b — Contact form (Contact Form 7) *(only when the brief's Must-haves include a contact form — skip otherwise)*
194
+
195
+ Run this only when the brief's **Must-haves** include a contact form. It installs **Contact Form 7**
196
+ (`contact-form-7`) on this same `/cms` install, so the frontend can render a contact page whose form
197
+ emails the site owner through WordPress's built-in `wp_mail()` — no third-party service and no API keys.
198
+ Like Yoast/WooCommerce this is **join-half** work: run it once `provision.status == "completed"`. The form
199
+ itself (id + fields + recipient) — and a tiny **spam-guard plugin** that makes a honeypot work headlessly —
200
+ are resolved/deployed in the content-model push — see
201
+ [`../../04-content-model/wordpress/contact-form.md`](../../04-content-model/wordpress/contact-form.md).
202
+
203
+ > **Why CF7 and not a bespoke mailer.** A contact form is maintained, spam-hardened functionality worth
204
+ > reusing rather than reinventing (you *can* deploy a custom plugin to a headless `/cms` — see the
205
+ > content-model cookbook — but you needn't write your own mailer). CF7 is a wordpress.org-slug plugin,
206
+ > installs through the same management MCP as Yoast, and ships a **public, unauthenticated** submit
207
+ > endpoint (`POST /cms/wp-json/contact-form-7/v1/contact-forms/<id>/feedback`). The frontend and `/cms`
208
+ > are same-origin, so the browser posts to it directly — no proxy, no CORS, no secret on the client.
209
+
210
+ 1. **Install + activate CF7** (management MCP, async — poll, never blind-retry):
211
+ - `wordpress_plugins_install { username, software: <install_id>, plugins: ["contact-form-7"] }`
212
+ - Poll `wordpress_plugins_list-installed { username, software }` until `contact-form-7` shows `active`
213
+ (install + activate can complete together), or activate it with
214
+ `wordpress_plugins_activate { username, software, plugin: "contact-form-7" }`.
215
+ - **Don't install the `contact-form-7-honeypot` add-on.** It only acts when a `[honeypot]` form-tag is
216
+ present in the form — a wp-admin edit the headless agent can't make — so on the default form it does
217
+ nothing. Headless spam protection instead comes from a small **spam-guard plugin deployed in the
218
+ content-model push** (it hooks `wpcf7_spam`); see the cookbook.
219
+ - **Known quirk:** the `hostinger-wordpress` MCP sometimes returns **HTTP 500 even though the job
220
+ succeeded**. Treat a 500 as "verify, don't assume": poll `list-installed` before retrying, and
221
+ never fire a second install blindly.
222
+ 2. **Verify** `GET https://<domain>/cms/wp-json/` lists the **`contact-form-7/v1`** namespace, then
223
+ record `contact_form.enabled: true` in `site.json` (Step 6). If the install can't be confirmed after
224
+ one retry, record `contact_form.enabled: false`, tell the user in one line, and let the frontend
225
+ render the contact page without a form (graceful — not a build failure). Don't fail provision over it.
226
+
227
+ The owner's email is **CF7's default recipient** (`[_site_admin_email]`, the admin address from
228
+ install) — fine out of the box, so "does Hostinger set up the email?" is **yes** for basic delivery via
229
+ `wp_mail`. Deliverability (SPF/DKIM) is most reliable when that address is a mailbox **on the same
230
+ domain**; the content-model push keeps the default recipient and validate sends a test submission to
231
+ confirm mail actually leaves. Never ask the user for mail credentials.
232
+
193
233
  ## Step 6 — Record `site.json`
194
234
 
195
235
  ```json
@@ -241,6 +281,29 @@ No passwords or tokens in `site.json`. When the user needs wp-admin, mint a one-
241
281
  }
242
282
  ```
243
283
 
284
+ **For a brief with a contact form (CF7 installed in Step 5b), also add a `contact_form` block.**
285
+ Provision records only `enabled` + `plugin` here; the content-model push fills `form_id`,
286
+ `feedback_url`, `fields` and `honeypot_field` once it resolves the form and deploys the spam guard:
287
+
288
+ ```json
289
+ {
290
+ "contact_form": {
291
+ "enabled": true,
292
+ "plugin": "contact-form-7",
293
+ "form_id": null,
294
+ "feedback_url": null,
295
+ "fields": [],
296
+ "honeypot_field": null,
297
+ "recipient": "site-admin-email"
298
+ }
299
+ }
300
+ ```
301
+
302
+ `form_id` / `feedback_url` / `fields` / `honeypot_field` stay `null` / `[]` until content-model resolves the form (the
303
+ submit URL is `https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`).
304
+ `recipient` is informational — CF7's `To` is `[_site_admin_email]`; no address, token or secret is
305
+ stored in `site.json`.
306
+
244
307
  `rest_api` stays `wp/v2` (the WordPress content contract, for the blog/pages on the same site);
245
308
  `store_api` is the public storefront surface (browse/cart/coupons/checkout, no auth). `wc_rest_api`
246
309
  (`wc/v3`) is recorded only as the surface the **owner's own** integrations use with a consumer key they
@@ -41,6 +41,20 @@ authenticated; the admin user context (`wp_set_current_user`) exists only *insid
41
41
  > (no custom plugin, no consumer key). Do the WordPress content below as normal, then run the **WooCommerce
42
42
  > catalog** section before you hand off.
43
43
 
44
+ > **Needs more than posts and pages? See [`custom-plugins.md`](custom-plugins.md).** The per-site MCP can
45
+ > create content *data* but can't register a **custom post type**, a **custom taxonomy**, or a **global
46
+ > settings page** (footer text, phone, social links the frontend reads). When the content model calls for
47
+ > those, author a small custom plugin and deploy it into the headless `/cms` with
48
+ > `hosting_deploy { type: "wordpress-plugin", headless: true }`, exposing everything over REST for the
49
+ > frontend. **Deploy the plugin first** (the schema — the deploy activates it), then the steps below populate
50
+ > its entries through the MCP's native `cpt-create` ability (custom-taxonomy terms are seeded in the plugin — see the cookbook).
51
+
52
+ > **A contact form on this site? See [`contact-form.md`](contact-form.md).** When the brief's
53
+ > **Must-haves** include a contact form (`site.json → contact_form.enabled: true`), provision installed
54
+ > **Contact Form 7**; this push resolves the form's numeric id + field names (in the **same session**)
55
+ > and records them into `site.json` so the Step 6 frontend can submit to CF7's public feedback endpoint.
56
+ > Run the **Contact form** section after the WordPress content below.
57
+
44
58
  ## Step 1 — Open the authenticated MCP session
45
59
 
46
60
  Mint a short-lived token through the Hostinger connector:
@@ -75,9 +89,10 @@ what's there; with discovery, list abilities, then execute by ability name. The
75
89
  cover this step (exact names come from discovery — don't hardcode them):
76
90
 
77
91
  `SettingsTools` · `CategoriesTools` · `TagsTools` · `MediaUploadTools` / `MediaTools` · `PagesTools` ·
78
- `PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`. A generic
79
- `RestEndpointTool` can reach any `wp/v2` route *through the authenticated session* if no dedicated tool
80
- fits.
92
+ `PostsTools` · `MenusTools` / `NavigationTools` · `SeoTools`, plus **`cpt-*`** for custom post types. There
93
+ is **no** generic `wp/v2` passthrough tool — every write goes through a dedicated ability, so a `wp/v2`
94
+ route with no matching ability (e.g. the terms of a *custom* taxonomy) isn't reachable from the MCP;
95
+ register and seed those in a custom plugin instead (see the backend's custom-plugins cookbook).
81
96
 
82
97
  Seen on a live install (Hostinger AI Assistant, discovery mode): the meta-tools are
83
98
  `hostinger-ai-discover-abilities`, `hostinger-ai-get-ability-info { name }` and
@@ -92,12 +107,18 @@ create answers **`200`** (not `201`) and the new object's `id` is **inside** `re
92
107
  ## Step 3 — Idempotency rule (makes resume safe)
93
108
 
94
109
  Every create is **list/get-by-slug first, then create only if absent** — a re-run must not duplicate.
95
- Use the matching read ability (or `RestEndpointTool` → `GET /wp/v2/posts?slug=…&status=any`): a hit →
96
- reuse its `id`; empty → create. Capture each returned `id` and record the id map back into
110
+ Use the matching read ability (`posts-search`, `pages-search`, `categories-list`, `tags-list`,
111
+ `cpt-search`, `media-search`, …): a hit → reuse its `id`; empty → create. Capture each returned `id` and record the id map back into
97
112
  `content-model.md` so later references resolve without re-querying.
98
113
 
99
114
  ## Step 4 — Create in dependency order
100
115
 
116
+ > **Custom types/settings? Deploy the plugin before this list.** If the content model needs a custom post
117
+ > type, taxonomy or global settings page, deploy that plugin **first** (the deploy activates it — see
118
+ > [`custom-plugins.md`](custom-plugins.md)) so its REST routes exist — then the steps below populate its
119
+ > entries through the MCP's native `cpt-create` ability, as they do core posts and pages (custom-taxonomy
120
+ > terms are seeded in the plugin — the MCP has no taxonomy-term path; see the cookbook).
121
+
101
122
  0. **Remove WordPress's default content first.** A fresh install ships a **"Hello world!" post**
102
123
  (id 1), a **"Sample Page"**, and a default comment — they will otherwise show up on the live site.
103
124
  Delete the default post (`posts-delete { id: 1, force: true }`) and, if present, the sample page
@@ -216,6 +237,38 @@ the storefront Store API and the checkout gate): [`woocommerce.md`](woocommerce.
216
237
  > finishes it in wp-admin). Seeding + verifying the catalog over the Store API is the whole job; never call
217
238
  > `/checkout` or block on it. See the cookbook §4.
218
239
 
240
+ ## Contact form (Contact Form 7) *(only when `site.json → contact_form.enabled` is true — skip otherwise)*
241
+
242
+ Provision installed Contact Form 7. Here, resolve the form the frontend submits to, deploy a tiny
243
+ spam-guard plugin (a headless honeypot), and record both into `site.json`. Full recipe:
244
+ [`contact-form.md`](contact-form.md). In short:
245
+
246
+ 1. **Find the form id (idempotent).** The per-site MCP has **no generic REST ability** and
247
+ `wpcf7_contact_form` is not a public CPT, so read the id by **probing the public feedback endpoint**:
248
+ a **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with
249
+ `_wpcf7_unit_tag=wpcf7-f<id>-o1`, for `id` in `1`‒`30`. The hit is the id whose `200` JSON reply carries
250
+ `"contact_form_id": <id>` (plus a CF7 `"status"`) — the default **"Contact form 1"** (fields `your-name`,
251
+ `your-email`, `your-subject`, `your-message`); `404`/`wpcf7_not_found` is a miss, and anything else
252
+ (`415`, `403`, `429`, `5xx`) is inconclusive, never a hit. **The content type matters:** CF7 answers
253
+ `415` to a non-multipart body *before* it looks up the id, so a bare POST would wrongly pick id 1. Reuse
254
+ it on re-run. CF7's default mail already sends to `[_site_admin_email]`, so no recipient change is needed.
255
+ *(Fallback if nothing answers: hand the owner the one-click wp-admin login — `wordpress_login_create-links`
256
+ — to read the id from Contact → Contact Forms, the WooCommerce-checkout-gate owner-handoff pattern.)* See
257
+ the cookbook for the full method.
258
+ 2. **Deploy the spam guard (honeypot).** The `contact-form-7-honeypot` add-on can't work headlessly (it
259
+ needs a `[honeypot]` form-tag only an owner can add), so deploy a ~15-line `wpcf7_spam` plugin to `/cms`
260
+ with `hosting_deploy { type: "wordpress-plugin", slug: "cf7-headless-spamguard", headless: true }` — it
261
+ flags a submission as `spam` (no mail) when a fixed decoy field (`your_website`) is filled. Cookbook §2b.
262
+ 3. **Record into `site.json`** — fill the `contact_form` block provision started: `form_id`,
263
+ `feedback_url` (`{urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`), `fields`
264
+ (the form's input names, e.g. `["your-name","your-email","your-subject","your-message"]`) and
265
+ `honeypot_field` (`"your_website"`, the decoy the frontend renders empty). Note the form id in
266
+ `content-model.md` under `## Created in WordPress`.
267
+
268
+ > **Don't send a real test here.** Resolving the form id + fields is the whole job for this stage; the
269
+ > throwaway `mail_sent` test belongs to **validate** (Step 7), so one dummy email reaches the owner, not
270
+ > several. See the cookbook for the feedback contract and the hidden `_wpcf7*` fields the frontend needs.
271
+
219
272
  ## Step 6 — Hand off
220
273
 
221
274
  Update `state.json` (`provision` stays `completed`; advance `stage` toward the frontend **data**
@@ -0,0 +1,227 @@
1
+ # headless — Contact Form 7 cookbook (contact form on headless WordPress)
2
+
3
+ A companion recipe to `SKILL.md` (WordPress content model). Use it whenever the brief's **Must-haves**
4
+ include a contact form (`site.json → contact_form.enabled: true`). The goal: a contact page whose form,
5
+ submitted from the **decoupled frontend**, sends the enquiry to the site owner by email using
6
+ WordPress's built-in `wp_mail()` — no third-party form service, no API keys, no secret on the client.
7
+
8
+ **Contact Form 7 is not a separate backend.** It is the `contact-form-7` plugin on the same headless
9
+ WordPress install at `/cms` (installed in the provision step). This cookbook is the authoritative home for
10
+ the contact-form data layer: how to **resolve the form** (content-model push), **deploy a spam guard**,
11
+ what the **frontend submits** (Step 6), and how **validate** proves mail actually leaves.
12
+
13
+ ---
14
+
15
+ ## 1 — Why CF7, and why it works headlessly
16
+
17
+ You **could** deploy a custom plugin into the headless `/cms` and write a bespoke mailer (see
18
+ [`custom-plugins.md`](custom-plugins.md)), but a contact form is exactly the kind of maintained,
19
+ spam-hardened functionality you should **reuse rather than reinvent**. CF7 is the right choice because:
20
+
21
+ - It is a **wordpress.org-slug plugin** → installs through the same management MCP (`wordpress_plugins_install`)
22
+ as Yoast and WooCommerce. No custom code to deploy.
23
+ - It ships a **public, unauthenticated submit endpoint**:
24
+ `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<form_id>/feedback`. Submissions don't need
25
+ the JWT or any key.
26
+ - The frontend (apex domain) and `/cms` are **same-origin**, so the browser posts straight to that
27
+ endpoint — **no proxy route, no CORS, no credentials client-side.**
28
+ - On submit CF7 runs its mail template through `wp_mail()`, which on Hostinger routes through the
29
+ server's mailer. The default form's `To` is `[_site_admin_email]` — the admin address set at install —
30
+ so **basic email delivery works out of the box.**
31
+
32
+ **Spam protection comes from a tiny deployed plugin, not the Honeypot add-on.** CF7's own
33
+ `contact-form-7-honeypot` add-on only acts when a `[honeypot]` form-tag is present in the form — a wp-admin
34
+ edit the headless agent can't make — so on the default form it does **nothing**. Instead, deploy a
35
+ ~15-line `wpcf7_spam` guard into `/cms` (the capability from [`custom-plugins.md`](custom-plugins.md)): it
36
+ rejects a submission whose decoy field is filled, with no owner step and no keys. See *§2b — Spam
37
+ protection* below. (reCAPTCHA is the alternative but needs owner-provided Google keys set in wp-admin —
38
+ out of reach for the headless agent.)
39
+
40
+ ---
41
+
42
+ ## 2 — Content-model push: resolve the form and record it
43
+
44
+ A fresh CF7 install ships one form, **"Contact form 1"**, with the default fields `your-name`,
45
+ `your-email`, `your-subject`, `your-message` and a submit, and mails `[_site_admin_email]`. You need
46
+ its **numeric form id** (and, if you tailored the form, its field names) to build `feedback_url`.
47
+
48
+ > **Reality check — how to read the id.** The per-site WP MCP (Hostinger AI Assistant, discovery mode)
49
+ > exposes **no generic REST/`RestEndpointTool` ability**, and `wpcf7_contact_form` is **not a public CPT**
50
+ > (it is absent from `cpt-list-types` and from `wp/v2/types`), so the MCP cannot read the form, and CF7's
51
+ > own `GET /contact-form-7/v1/contact-forms` is capability-gated (no public read). The reliable,
52
+ > tool-free way is to **probe the public feedback endpoint**, which needs no auth.
53
+
54
+ 1. **Find the form id by probing the feedback endpoint (idempotent).** For `id` in a small range
55
+ (`1`‒`30` covers a fresh install — the default form lands a few ids after the sample content), send a
56
+ **`multipart/form-data`** `POST {urls.cms}/wp-json/contact-form-7/v1/contact-forms/<id>/feedback` with
57
+ a single field `_wpcf7_unit_tag=wpcf7-f<id>-o1`. **The content type matters:** CF7 checks it *before*
58
+ it looks up the id and answers **415** to any non-multipart body, so a bare POST returns non-404 for
59
+ *every* id and would wrongly pick id 1.
60
+ - **Hit** → `200` whose JSON body carries `"contact_form_id": <id>` (matching the id you probed) and a
61
+ CF7 `"status"` (e.g. `validation_failed`). **This id is the form.** On a fresh single-form install
62
+ that is the only hit; reuse it on a re-run.
63
+ - **Miss** → `404` with `"code": "wpcf7_not_found"`. Keep going.
64
+ - **Inconclusive** → anything else (`415` from a wrong content type, `403`, `429`, `5xx`) is **not** a
65
+ hit — never select such an id. Fix the request or retry, widen the range, and fall back to the owner
66
+ handoff (step 2) if nothing resolves.
67
+
68
+ The default field names are `your-name`, `your-email`, `your-subject`, `your-message`. (A full test
69
+ submission that confirms both the id and the field names — and that mail actually sends — belongs to
70
+ **validate**, so you send just one real email; see §4.)
71
+
72
+ 2. **(Optional) tailor the form — owner-side.** The headless tooling can't edit the CF7 form (no REST
73
+ ability reaches it). If the design needs different fields, keep the default form for the build and note
74
+ it; the owner edits fields/recipient in wp-admin (Contact → Contact Forms) via the one-click login.
75
+ The default form is enough for a name/email/subject/message enquiry, and its recipient
76
+ (`[_site_admin_email]`) is already correct.
77
+
78
+ 3. **Record into `site.json`** — fill the `contact_form` block provision started:
79
+
80
+ ```json
81
+ {
82
+ "contact_form": {
83
+ "enabled": true,
84
+ "plugin": "contact-form-7",
85
+ "form_id": 4,
86
+ "feedback_url": "https://<domain>/cms/wp-json/contact-form-7/v1/contact-forms/4/feedback",
87
+ "fields": ["your-name", "your-email", "your-subject", "your-message"],
88
+ "cf7_version": "6.2.2",
89
+ "honeypot_field": "your_website",
90
+ "recipient": "site-admin-email"
91
+ }
92
+ }
93
+ ```
94
+
95
+ `cf7_version` is the installed CF7 version (from `wordpress_plugins_list-installed`) — the frontend
96
+ sends it as `_wpcf7_version`. `honeypot_field` is the decoy the spam guard checks (§2b) — the frontend
97
+ renders it empty and the server rejects a filled one. Also note the form id in `content-model.md`. No
98
+ address, token or secret goes into `site.json`.
99
+
100
+ > **Fallback if the probe finds nothing** (an unusual install, or the default form was removed): mint a
101
+ > one-click wp-admin link (`wordpress_login_create-links { username, software }`) and ask the owner to
102
+ > read the form id from **Contact → Contact Forms** (the shortcode shows `id="…"`). Record it into
103
+ > `site.json` and continue — the same owner-handoff used for the WooCommerce checkout gate, not a build
104
+ > failure.
105
+
106
+ > **Aside — Hostinger's own shortcode.** The per-site MCP's `pages-create` ability suggests
107
+ > `[hostinger_contact_form]` for a contact form *inside a WordPress page*. That is server-rendered
108
+ > shortcode output, not useful to a decoupled React frontend that reads REST JSON — this flow uses CF7's
109
+ > JSON feedback endpoint instead. Mentioned only so the shortcode hint isn't mistaken for the headless path.
110
+
111
+ ---
112
+
113
+ ## 2b — Spam protection: deploy the honeypot guard
114
+
115
+ CF7's default form has **no spam protection**, and the `contact-form-7-honeypot` add-on can't help
116
+ headlessly (it needs a `[honeypot]` form-tag only an owner can add — see §1). Instead, deploy a tiny
117
+ plugin that enforces a honeypot **server-side**, on the same REST feedback path the frontend uses. This
118
+ reuses the custom-plugin deploy from [`custom-plugins.md`](custom-plugins.md) — the only moving part is one
119
+ ~15-line file.
120
+
121
+ **Why it works.** CF7's `wpcf7_spam` filter runs inside `submit()`, which the REST feedback endpoint
122
+ calls, so returning `true` sets the submission `status` to `spam` and **suppresses the mail**. The hook can
123
+ read any posted field via `WPCF7_Submission::get_posted_data()` — including a decoy field that isn't a CF7
124
+ form-tag, **as long as its name doesn't start with `_`** (CF7 strips `_`-prefixed keys). So the frontend
125
+ sends a decoy field (empty for humans), and the guard flags the submission as spam when a bot fills it.
126
+
127
+ 1. **Write the guard** to `~/.hostinger/<domain>/plugins/cf7-headless-spamguard/cf7-headless-spamguard.php`:
128
+
129
+ ```php
130
+ <?php
131
+ /**
132
+ * Plugin Name: CF7 Headless Spam Guard
133
+ * Description: Server-side honeypot for Contact Form 7 submissions from a headless frontend.
134
+ * Version: 1.0.0
135
+ * Requires PHP: 7.4
136
+ * License: GPL-2.0-or-later
137
+ */
138
+ if ( ! defined( 'ABSPATH' ) ) { exit; }
139
+
140
+ // Reject a submission whose decoy field is filled. The name is a fixed convention the frontend
141
+ // renders empty (site.json → contact_form.honeypot_field); it must NOT start with "_".
142
+ add_filter( 'wpcf7_spam', function ( $spam, $submission ) {
143
+ if ( $spam ) { return $spam; } // keep any earlier spam verdict
144
+ $decoy = $submission->get_posted_data( 'your_website' );
145
+ if ( is_string( $decoy ) && trim( $decoy ) !== '' ) {
146
+ $spam = true; // bot filled it → status 'spam', no mail
147
+ }
148
+ return $spam;
149
+ }, 10, 2 );
150
+ ```
151
+
152
+ 2. **Deploy it** into the headless `/cms` (activates on install; idempotent — re-deploy to update):
153
+
154
+ ```json
155
+ { "operation": "hosting_deploy",
156
+ "params": {
157
+ "domain": "<domain>",
158
+ "type": "wordpress-plugin",
159
+ "path": "~/.hostinger/<domain>/plugins/cf7-headless-spamguard",
160
+ "slug": "cf7-headless-spamguard",
161
+ "headless": true } }
162
+ ```
163
+
164
+ 3. **Record** `contact_form.honeypot_field: "your_website"` in `site.json` (done in §2 step 3) so the
165
+ frontend knows which decoy to render. Keep the plugin **generic** (no site-specific values) — it's one
166
+ reusable artifact, not per-site code.
167
+
168
+ > **Nonce note.** CF7's built-in spam check also flags nonce failure, but nonce verification is **inactive
169
+ > for logged-out visitors by default** — which is why a plain headless submission returns `mail_sent`, not
170
+ > `spam`. The guard adds the honeypot on top. Confirm end-to-end in validate (§4).
171
+
172
+ ---
173
+
174
+ ## 3 — What the frontend submits (Step 6 reference)
175
+
176
+ The feedback endpoint expects `multipart/form-data` with the form's own fields **plus** CF7's hidden
177
+ control fields. The frontend builds this in a `submitContactForm(data)` helper in the one data module:
178
+
179
+ | Field | Value |
180
+ |---|---|
181
+ | each name in `contact_form.fields` | the user's input (`your-name`, `your-email`, …) |
182
+ | `_wpcf7` | `contact_form.form_id` (number) |
183
+ | `_wpcf7_version` | CF7 version string, e.g. `"5.9.8"` (not strictly validated) |
184
+ | `_wpcf7_locale` | e.g. `"en_US"` |
185
+ | `_wpcf7_unit_tag` | any stable string, e.g. `"wpcf7-f<form_id>-o1"` |
186
+ | `_wpcf7_container_post` | `"0"` |
187
+ | `contact_form.honeypot_field` (the decoy, e.g. `your_website`) | **empty** — rendered hidden/off-screen, `autocomplete="off"`, `tabindex="-1"`; the §2b guard rejects it as spam if filled |
188
+
189
+ `POST` to `contact_form.feedback_url` and **do not set `Content-Type`** — let the browser add the
190
+ multipart boundary. Same-origin, so no CORS and no secret.
191
+
192
+ **Response** is JSON:
193
+
194
+ ```json
195
+ { "contact_form_id": 42, "status": "mail_sent", "message": "Thank you for your message. It has been sent.",
196
+ "invalid_fields": [], "posted_data_hash": "…" }
197
+ ```
198
+
199
+ Branch on `status`:
200
+
201
+ - `mail_sent` → success state (clear the form, show the thank-you message).
202
+ - `validation_failed` → map each `invalid_fields[].field` to its input and show the message inline.
203
+ - `spam` / `aborted` / `mail_failed` / `acceptance_missing` → a generic "couldn't send, try again" error.
204
+
205
+ A user validation error is a **normal UI state, not a build error**. When `contact_form.enabled` is
206
+ `false` or absent, the frontend renders the contact page with plain contact details and **no form**.
207
+
208
+ ---
209
+
210
+ ## 4 — Email delivery & the `wp_mail` caveat (what to tell the owner)
211
+
212
+ CF7's default form mails `[_site_admin_email]` via `wp_mail()`, so **Hostinger does "set up" basic email
213
+ by default** — nothing else to configure for the form to send. Two caveats worth surfacing:
214
+
215
+ - **Deliverability.** `wp_mail` from a shared host can land in spam for external recipients without
216
+ SPF/DKIM. The most reliable recipient is a **Hostinger mailbox on the same domain**. Keep CF7's admin
217
+ recipient; if the owner wants a different inbox, point them to a same-domain mailbox.
218
+ - **Prove it, don't assume it.** Validate (Step 7) POSTs a throwaway test submission and asserts
219
+ `status == "mail_sent"`. A `mail_failed` is a real finding — report it (CF7 is wired, but the mailer
220
+ needs attention), don't pass silently.
221
+ - **Check the honeypot too.** Send one more throwaway submission with the decoy
222
+ (`contact_form.honeypot_field`) **filled** — it must come back `status: "spam"` with **no** mail. If it
223
+ returns `mail_sent`, the §2b guard plugin isn't active (re-check its deploy). The normal empty-decoy
224
+ submission still returns `mail_sent`, so this costs one extra throwaway email at most.
225
+
226
+ Don't install an SMTP plugin or ask for mail credentials as part of this flow — the default `wp_mail`
227
+ path is the intended behaviour; SMTP is an owner decision in wp-admin if deliverability needs hardening.