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

11 KiB

The surface Nolimit Send exposes

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.

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

🔴 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.