diff --git a/README.md b/README.md index b5a6069..cabc165 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,39 @@ -# Nolimit Send: releases +# Nolimit Send -This repository holds the release zips for the Nolimit Send WordPress plugin, and nothing else. WordPress reads it to offer updates. +![Nolimit Send — the screen where you pick how the site sends mail](assets/hero.png) -Download the latest zip from Releases. +Chooses how WordPress and WooCommerce send email. Pick SMTP2GO, Brevo, Mailgun, +SendGrid, Postmark, Amazon SES, Google or Microsoft — or type in any SMTP +server — and every message the site sends goes through it. One screen, no +account, no telemetry, no paid tier. + +## Install + +1. Download `nolimit-send.zip` from the [latest release](https://gitea.nolimit.dk/kw/nolimit-send-dist/releases/latest). +2. **Plugins → Add New → Upload Plugin**, then activate. +3. Open **Send** in the Nolimit menu, pick a provider, and press + *Send a test message* — [the setup guide](docs/SETUP.md) says what each + provider wants in the password field. + +Nothing is sent through it until you switch it on. Later versions arrive in +**Dashboard → Updates** like any other plugin. + +## Documentation + +- [Overview](docs/README.md) — what it does, and what it deliberately does not +- [Setting it up, and knowing it works](docs/SETUP.md) — provider by provider, + how to check a message really went through your provider, and what to do + when mail stops +- [Hooks and integration](docs/CONTRACT.md) — for developers: what the plugin + hooks into, what it stores, and what it leaves alone + +## More of it + +| | | +|---|---| +| ![Microsoft 365 preset](assets/screenshots/microsoft.png) | **Honest about Microsoft 365.** Password sign-in for SMTP is being retired and this plugin does not do OAuth, so the Microsoft preset says so on the screen instead of working until it quietly stops. | +| ![A failed test message](assets/screenshots/test-failure.png) | **The server's own answer.** A failed test shows what the mail server said, and the last real failure is kept with its time, so "why did that email not arrive" has an answer. | +| ![What the test can and cannot tell you](assets/screenshots/limits.png) | **No pretending.** SMTP hands a message over; it cannot report bounces or spam complaints. The screen says what a test proves and what only your provider's dashboard can tell you. | + +Requires WordPress 5.9+ and PHP 7.4+. WooCommerce is not required, but it is +what the plugin is tested against. diff --git a/assets/hero.png b/assets/hero.png new file mode 100644 index 0000000..b1b83b8 Binary files /dev/null and b/assets/hero.png differ diff --git a/assets/screenshots/limits.png b/assets/screenshots/limits.png new file mode 100644 index 0000000..89210fb Binary files /dev/null and b/assets/screenshots/limits.png differ diff --git a/assets/screenshots/microsoft.png b/assets/screenshots/microsoft.png new file mode 100644 index 0000000..aba9f83 Binary files /dev/null and b/assets/screenshots/microsoft.png differ diff --git a/assets/screenshots/test-failure.png b/assets/screenshots/test-failure.png new file mode 100644 index 0000000..13f4052 Binary files /dev/null and b/assets/screenshots/test-failure.png differ diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md new file mode 100644 index 0000000..5f19d6c --- /dev/null +++ b/docs/CONTRACT.md @@ -0,0 +1,198 @@ +# 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_` | 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_` | 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_` 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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..dbd2050 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# Nolimit Send + + + +Chooses how WordPress and WooCommerce send email. Pick a provider — SMTP2GO, +Brevo, Mailgun, SendGrid, Postmark, Amazon SES, Google, Microsoft — or type in +any SMTP server, and every message the site sends goes through it. + +## The problem it was written for + +By default WordPress hands mail to the web host's own `mail()`. That is the +mail that lands in spam, arrives from `wordpress@` a domain that has no SPF +record for the host, or is silently dropped — and a shop only finds out when a +customer writes to say the order confirmation never came. + +The fix is to send through a real mail service over SMTP. The plugins that do +it are usually large, and several of them phone home or sell the upgrade on +the same screen where the password goes. This one is a single screen. + +## What it does, and what it refuses to do + +* **Routes everything.** It configures PHPMailer, the library behind + `wp_mail()`, so WordPress itself, WooCommerce orders and receipts, password + resets and every other plugin's mail go through the chosen server. It is + site-wide, not WooCommerce-only. +* **Presets** that fill in the host, port and encryption, with a note per + provider on what the password field really wants (Brevo wants an SMTP key, + Google an App Password). +* **A test message**, sent from the screen, that shows the server's own + answer when it fails. +* **The last failure**, kept and shown with a time, so "why did that email not + arrive" has an answer on the screen rather than in a log nobody opens. +* **A sender address and name**, replacing WordPress' default `wordpress@…` — + or, if forced, every sender. + +What it refuses to do: + +* **It never touches a message's own headers.** Reply-To, Cc, List-Unsubscribe + and anything a caller set are passed through exactly as they came. +* **It does not queue, throttle or retry.** A failed message is recorded, not + re-sent. +* **It cannot report bounces or complaints.** SMTP hands a message over; whether + it was delivered comes back, if at all, to the provider's dashboard. The + screen says so rather than implying a delivery report. +* **It does not do OAuth.** Google works with an App Password. Microsoft 365 is + retiring username-and-password SMTP, and the screen says that plainly on the + Microsoft preset — [the setup guide](SETUP.md#microsoft-365) has the dates. +* **It stands down** when another SMTP plugin (WP Mail SMTP, FluentSMTP, Post + SMTP, Easy WP SMTP, Brevo for WordPress) is active, and names it on the screen. + +## Documentation + +| File | For | +| --- | --- | +| [SETUP.md](SETUP.md) | Installing it, choosing a provider, checking it works, and what to do when mail stops. | +| [CONTRACT.md](CONTRACT.md) | For developers: every hook, option, capability and constant — and what is deliberately absent. | + +## Good to know + +* **No telemetry**, no account, no upsell. Nothing is sent anywhere except the + mail itself, to the server you named, and the update check against the + public release page, which carries no credential. +* **Updates** come from that release page rather than wordpress.org, and appear + in **Dashboard → Updates** like any other plugin's. +* The SMTP password is stored as typed in one option row, because WordPress has + nowhere better to keep it. It is never printed back into the form, and it is + removed when the plugin is deleted. +* The strings are English only. diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..6066218 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,109 @@ +# Setting up Nolimit Send + + + +## Installing it on a shop + +| Needs | Version | +| --- | --- | +| WordPress | 5.9 or newer | +| PHP | 7.4 or newer | +| WooCommerce | Not required. It is tested with WooCommerce (6.0 or newer) because that is the mail most shops care about | + +Download `nolimit-send.zip` from the +[latest release](https://gitea.nolimit.dk/kw/nolimit-send-dist/releases/latest), +install it through **Plugins → Add New → Upload Plugin**, and activate. +After the first install, updates arrive through the normal WordPress updates +screen — the plugin carries an `Update URI`, so it checks its own release feed +and is never asked about on wordpress.org. + +**Nothing is sent through it until it is configured.** After activation the +plugin is off: WordPress keeps sending the way it did. That is deliberate — a +mailer that switched itself on with no server behind it would take every email +the shop sends down with it. + +## Choosing how the site sends + +The screen is **Send**, under the shared Nolimit menu in the admin sidebar, +and needs the `manage_options` capability. + +1. Tick **Send through this server** and pick a provider. The host, port and + encryption are filled in from the preset; choosing a different provider + replaces all three, so there is never a mixed pair such as one provider's + name over another's host. +2. Enter the username and password the way the note under the field says. What + the field wants differs, and is the usual cause of a rejected login: + + | Provider | What goes in the password field | + | --- | --- | + | Brevo | An **SMTP key** from SMTP & API, not the account password and not an API key | + | SMTP2GO | That SMTP user's password. Port 2525 is the default: it is open from more networks than 587 | + | Mailgun | That SMTP user's password from the Mailgun dashboard | + | SendGrid | An API key with Mail Send permission; the username is the literal word `apikey` | + | Postmark | The server API token, in **both** the username and the password field | + | Amazon SES | The SES **SMTP** password, and the region's host typed in | + | Google | An **App Password**, which needs two-step verification on that account | + | Microsoft | The mailbox password, with SMTP AUTH enabled for that mailbox — see below | + | Other | Whatever your server expects | + +3. Optionally set a sender address and name. Unless **Always use this sender** + is ticked, they replace only WordPress' own default `wordpress@…` / + `WordPress` and leave any sender a plugin chose alone. +4. **Save**, then **Send a test message**. A failure shows the server's own + answer. A real failure is kept with its time; the status reads "a message + failed recently" for 24 hours, and a button clears it sooner. A later success + does not clear it early. + +### Microsoft 365 + +Username-and-password SMTP is being retired: switched off by default for +existing organisations from the end of December 2026, unavailable to new ones, +with a final date not yet announced. **This plugin does not do OAuth**, which is +what replaces it, so the Microsoft preset works until an administrator loses the +ability to enable it. For anything that has to keep working, use a sending +provider. The screen says the same thing on the preset itself. + +### Other mail plugins + +If WP Mail SMTP, FluentSMTP, Post SMTP, Easy WP SMTP or Brevo for WordPress is +active, this plugin does **not** send: two plugins that both configure PHPMailer +produce whatever the later one says. The screen names the plugin. Nolimit Forms +has an SMTP mailer of its own; that is only a notice, and the owner decides +which to switch off. + +### Auto-updates + +The plugin offers to switch its own auto-updates on, once, through a notice on +its screen. Taking or dismissing the offer writes a marker +(`nlsnd_auto_update_seeded`) so it is never asked again — the setting is +WordPress' own, in WordPress' own place, and the toggle is gated on +`update_plugins`. + +## Knowing it works + +* **Send a test message** from the screen. It goes through the server you chose + and, when it fails, shows that server's own answer — a refused login, a host + that cannot be reached, a sender the provider has not verified. +* **Read the status line** at the top of the screen. It reads *Sending through + * when everything is in place, *Not sending yet* when it is switched + off or has no host, *Mail is handled by * when another mail plugin is + in the way, and *A message failed recently* after a real failure. +* **Look at one real email.** Place a test order or use the password-reset + form, and open the message's full headers in the receiving mailbox. The + `Received:` lines should name your provider, not your web host. + +A test message reaching the provider proves the login and the route. It does not +prove the message reached an inbox: that answer lives in the provider's own +dashboard, and so do bounces and spam complaints. + +## If mail stops + +1. The screen's **last failure** card shows the most recent error with its time. + It is only recorded when this plugin was the one sending. +2. A password or key that was rotated at the provider is the commonest cause. + Enter the new one and save; the field is never filled in for you, and + leaving it empty keeps the stored one. +3. Untick **Send through this server** to hand sending straight back to + WordPress while you sort it out. Nothing else is changed. +4. Deleting the plugin from the Plugins screen removes every setting it stored, + including the password.