Files
nolimit-send-dist/docs/CONTRACT.md
T

199 lines
11 KiB
Markdown
Raw Normal View History

2026-10-07 10:01:36 +02:00
# The surface Nolimit Send exposes
2026-10-08 16:46:58 +00:00
<!-- nlsnd:version 0.1.1 -->
2026-10-07 10:01:36 +02:00
Everything another plugin, a theme or a site owner can bind to, store against,
or break by changing. It describes what the installed plugin actually does, not
what it might one day do: a table written from intent lists what a plugin means
to provide rather than what it provides.
The contract covers the files inside the installed plugin. Nothing else is part
of it.
## Hooks this plugin provides
**None.** This plugin fires no `do_action()` and applies no `apply_filters()`
of its own, so there is nothing here for anyone to hook.
That is a deliberate state rather than an oversight, and it is worth one line
so nobody adds one casually: every extension point is a promise that outlives
the reason it was added, and nothing has yet asked to extend this. In
particular there is no filter that lets another plugin change the SMTP
settings — that is exactly the door a credential leaks through. The moment
something does ask, the filter goes in this table in the same commit.
<!-- nlsnd:hooks:start -->
## Hooks this plugin registers on
🔴 Deliberately not headed "consumes". `add_filter()` is how you **answer** a
filter — you supply the value somebody else asked for — and reading it as
consumption gets the direction of the promise backwards. For an action the
word is *listens*; for a filter it is *answers*. The Kind column below says
which.
Priority and accepted-argument count are given where they are not WordPress'
defaults of `10` and `1`, because those two are what break silently — see the
`update_plugins_` row.
| Hook | Kind | Registered in | Why |
| --- | --- | --- | --- |
| `phpmailer_init` | action | `includes/class-mailer.php` | The whole job: points PHPMailer at the chosen server. Does nothing unless enabled, with a host, and no other mail plugin present. |
| `wp_mail_failed` | action | `includes/class-mailer.php` | Keeps the reason a message failed — time and message only — when this plugin was the one sending. |
| `wp_mail_from` | filter, priority 99 | `includes/class-mailer.php` | The sender address. 99 so WooCommerce's own callback at 10 has already had its say. |
| `wp_mail_from_name` | filter, priority 99 | `includes/class-mailer.php` | The sender name, on the same rule. |
| `plugins_loaded` | action | `includes/class-plugin.php` | Everything else is registered from here. |
| `admin_menu` | action | `includes/class-admin.php` | Registers the screen under the shared Nolimit menu. |
| `admin_init` | action | `includes/class-admin.php` | Handles the save, test and clear form posts, after the nonce and capability checks. |
| `admin_init` | action | `includes/class-updater.php` | Drops the cached feed when the user presses "Check again". |
| `admin_enqueue_scripts` | action | `includes/class-admin.php` | The one stylesheet, on this plugin's screen only. |
| `admin_post_nlsnd_auto_update` | action | `includes/class-auto-update.php` | The auto-update toggle's form target. Name is this plugin's; the hook is WordPress'. |
| `plugin_action_links_<basename>` | filter | `includes/class-admin.php` | The "Settings" link on the Plugins screen. Hook name is built from `NLSND_PLUGIN_BASENAME`. |
| `plugins_api` | filter, 3 args | `includes/class-updater.php` | The "View details" panel. Guarded on `$args->slug`. |
| `update_plugins_<hostname>` | filter, 3 args | `includes/class-updater.php` | Offers this plugin's own updates. |
| `upgrader_process_complete` | action, 0 args | `includes/class-updater.php` | Forgets the cached feed after any update runs. |
`send_test()` also adds and removes a `wp_mail_failed` listener around one
`wp_mail()` call, so the test button can say why a message did not go out. It
is removed again on the way out.
**Nothing hooks `wp_mail` itself.** No filter here can alter a message's
recipient, subject, body, headers or attachments, and the plugin's own checks
assert that `wp_mail` has no callback from it. That is the property that lets
the plugin be site-wide: it configures the transport and leaves the message
alone.
### 🔴 `update_plugins_<hostname>` is shared, and the argument count is load-bearing
The hook name is built from the **host**, not the repository, so every plugin
updating from `gitea.nolimit.dk` lands on the same filter and WordPress runs
every callback on it for every one of them. Answering unconditionally offers
this plugin's release to a sibling — which has happened on a live shop, and
installs a stranger's zip over a plugin with auto-updates on.
The defence is two halves and neither works alone: `add_filter( …, 10, 3 )`
**and** a guard returning `$update` untouched when `$plugin_file` is not this
plugin's basename. Leave the count at `2` and the third argument never
arrives, so the guard compares against an empty string and silently does
nothing — the same bug it was written to prevent, wearing a fix's clothes.
A test on a site with one plugin from that host cannot see this, because the
collision cannot arise there. The way to check it is to fire the real filter in
both directions: the neighbour is left alone, **and** this plugin still answers
for itself. The second half is not redundant. A guard that bails on everything
also leaves the neighbour alone.
## The family's shared hooks
These are the Nolimit-family surface, not this plugin's.
| Hook | This plugin's relationship | Where |
| --- | --- | --- |
| `wpmgr_dashboard_cards` | **answers** — supplies this plugin's card | `includes/class-admin.php` |
| `wpmgr_dashboard_cards` | applied by the shared file this plugin ships | `includes/shared-menu.php` |
| `wpmgr_menu_title` | applied by the shared file this plugin ships | `includes/shared-menu.php` |
| `wpmgr_menu_glyph` | applied by the shared file this plugin ships | `includes/shared-menu.php` |
| `admin_head` | listened to by the shared file this plugin ships | `includes/shared-menu.php` |
<!-- nlsnd:hooks:end -->
### 🔴 Shipping a file is not owning its hooks
`includes/shared-menu.php` is byte-identical across the Nolimit plugins.
Whichever copy loads **first** declares the functions; every later copy sees
them already defined and does nothing. So the three `wpmgr_*`
filters above are applied by *a* copy of that file, which on any real site is
probably another plugin's. Anyone depending on those filters should depend on
the family, not on this plugin being installed.
The card this plugin answers with carries `title`, `status`, `headline`,
`lines`, `url`, `action` and `colour`. `colour` is `NLSND_COLOUR`, which the
shared file paints as a card header with white text.
## The provider table — shared with Nolimit Forms
`NLSND_Mailer::providers()` is **hand-copied** from `NLFRM_Mailer::providers()`
in Nolimit Forms, which has an SMTP mailer of its own. Nothing shares the code
across the two plugins, so a difference between the copies is a defect that
neither plugin can see. The values that must agree, by id:
| Id | Host | Port | Encryption |
| --- | --- | --- | --- |
| `brevo` | `smtp-relay.brevo.com` | 587 | tls |
| `smtp2go` | `mail.smtp2go.com` | **2525** | tls |
| `mailgun` | `smtp.mailgun.org` | 587 | tls |
| `sendgrid` | `smtp.sendgrid.net` | 587 | tls |
| `postmark` | `smtp.postmarkapp.com` | 587 | tls |
| `ses` | *(none — region-specific, typed in)* | 587 | tls |
| `google` | `smtp.gmail.com` | 587 | tls |
| `microsoft` | `smtp.office365.com` | 587 | tls |
| `custom` | *(none — typed in)* | 587 | tls |
Cautions such as the Microsoft one are kept out of the table on purpose
(`NLSND_Mailer::warning()`), so one can change without touching the contract.
**Both plugins hook `phpmailer_init`.** With both switched on, the later
callback wins. Nolimit Forms is told this plugin is present by the constant
`NLSND_VERSION`, which is defined at include time for exactly that reason.
Nolimit Send reports Forms' SMTP on its own screen as a notice rather than
standing down for it.
## Stored data
Every row here is removed by `uninstall.php` on delete — not on deactivate,
because switching a plugin off for ten seconds to test something must not
destroy the shop's mail settings.
| Key | Kind | Holds |
| --- | --- | --- |
| `nlsnd_settings` | option, not autoloaded | Every setting, including the SMTP password as typed. |
| `nlsnd_last_failure` | option | The last delivery failure: a time and a message capped at 400 characters. |
| `nlsnd_release_feed` | transient, 15 min | The cached update feed. |
| `nlsnd_auto_update_seeded` | option | A marker, so the auto-update default is offered once and never re-applied. |
The password is stored in clear because WordPress has nowhere to keep a secret;
anyone who can read the options table can read it. It is never rendered back
into a form. Mail content — recipient, subject, body — is **never** stored, and
there is no send log.
## Capabilities
| Capability | Gates |
| --- | --- |
| `manage_options` | Seeing the screen, saving settings, sending a test, clearing the last failure. |
| `update_plugins` | The auto-update toggle only. WordPress' own capability for the same decision. |
## Constants
`NLSND_VERSION`, `NLSND_PLUGIN_FILE`, `NLSND_PLUGIN_DIR`, `NLSND_PLUGIN_URL`,
`NLSND_PLUGIN_BASENAME`, `NLSND_COLOUR`.
`NLSND_VERSION` has a reader outside this repository: Nolimit Forms asks whether
it is defined. It must stay defined at include time, not from a hook.
`NLSND_COLOUR` (`#556b0f`) is the other one with a reader outside this
plugin: the shared menu paints it as a dashboard card background with
hardcoded white text, which it measures at 6.01:1 against white, over the 4.5:1
the 14px label needs. It exists in three places that must stay the same string —
the `define()`, the stylesheet's `--nlsnd-accent`, and `assets/icon.svg`.
## What is deliberately not here
Stated because an absent surface is invisible, and the next person to want one
should find the reason rather than the silence.
* **No WP-CLI commands, no REST routes, no AJAX endpoints.** The screen is a
form post handled on `admin_init`.
* **No JavaScript.** One stylesheet, no script is enqueued.
* **No cron, no scheduled events.** Nothing runs on a timer; the update feed is
fetched lazily behind a transient.
* **No queue, throttle or retry.** A failed message is recorded and not re-sent.
* **No send log, and no bounce or complaint handling.** SMTP cannot report them.
* **No OAuth.** Google needs an App Password; Microsoft's password sign-in is
being retired and the screen says so.
* **No hook on `wp_mail`, and no filter over the SMTP credentials.**
* **No user meta, post meta or term meta.**
* **No telemetry of any kind.** The only network traffic is the mail itself, to
the server the owner named, and the update check against the public release
repository, which sends no credential.