Files

323 lines
22 KiB
Markdown

# The surface Nolimit News exposes
<!-- nlnws:version 0.2.2 -->
Everything another plugin, a theme or a site owner can bind to, store against,
or break by changing. For developers; a site owner never needs to read it.
"Shipped source" means the files in the plugin zip. Anything else in the
project is not on a site and is not part of this contract.
## Hooks this plugin provides
**None yet.** 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 state, not a promise. Every extension point outlives the reason it
was added, so each goes into this table in the same commit as the code.
`nlnws_` is the prefix, and the release checks fail if a hook with it exists
in the code and not in this table, or the other way round.
<!-- nlnws:hooks:start -->
## Hooks this plugin registers on
Deliberately not headed "consumes": `add_filter()` is how you **answer** a
filter, 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*.
Priority and accepted-argument count are given where they are not WordPress'
defaults of `10` and `1`, because those two are what break silently.
| Hook | Kind | Registered in | Why |
| --- | --- | --- | --- |
| `admin_init` | action | `includes/class-updater.php` | Drops the cached feed when the user presses "Check again". |
| `admin_init` | action | `includes/class-privacy.php` | Offers a paragraph for Settings → Privacy → Policy Guide. |
| `admin_enqueue_scripts` | action | `includes/class-admin.php` | Loads `assets/css/admin.css`, on this plugin's screen only. |
| `admin_menu` | action | `includes/class-admin.php` | Registers the Newsletters screen, and a link to it in the shared Nolimit menu. |
| `admin_post_nlnws_auto_update` | action | `includes/class-auto-update.php` | The auto-update toggle's form target. |
| `admin_post_nlnws_export` | action | `includes/class-subscribers-screen.php` | Streams the subscriber CSV. A GET link carrying a nonce; nothing is written. |
| `admin_post_nlnws_issue_action` | action | `includes/class-send-screen.php` | Send now, schedule, test send, stop, resume, or choose the template for one issue. |
| `admin_post_nlnws_settings_save` | action | `includes/class-templates-screen.php` | Saves the brand colour, logo, postal address and default template. |
| `admin_post_nlnws_template_delete` | action | `includes/class-templates-screen.php` | Removes one pasted-in template. |
| `admin_post_nlnws_template_save` | action | `includes/class-templates-screen.php` | Checks a pasted-in template and stores it, or refuses it with the reasons. |
| `admin_post_nlnws_list_delete` | action | `includes/class-lists-screen.php` | Deletes a list and its memberships. Subscribers stay. |
| `admin_post_nlnws_list_save` | action | `includes/class-lists-screen.php` | Creates or renames a list. |
| `admin_post_nlnws_forms_save` | action | `includes/class-forms-bridge.php` | Saves which Nolimit Forms form signs people up to which list. |
| `admin_post_nlnws_import` | action | `includes/class-import.php` | Import a CSV file (a Mailchimp audience export, or any file with an address column): subscribed, unsubscribed or cleaned. |
| `admin_post_nlnws_reconfirm` | action | `includes/class-import.php` | Mail the next 50 imported, unconfirmed people and ask whether they still want the newsletter. |
| `admin_post_nopriv_nlnws_open` | action | `includes/class-opens.php` | The open-counting pixel, for a logged-out reader. Answers the same 1x1 GIF whether or not anything was recorded. A GET that records, deliberately: it changes no consent. |
| `admin_post_nlnws_open` | action | `includes/class-opens.php` | The same, for a reader who happens to be logged in. |
| `admin_post_nopriv_nlnws_confirm` | action | `includes/class-public.php` | The link in the confirmation mail, for a logged-out visitor. GET shows a button; only POST confirms. |
| `admin_post_nlnws_confirm` | action | `includes/class-public.php` | The same, for a visitor who happens to be logged in. |
| `admin_post_nopriv_nlnws_signup` | action | `includes/class-public.php` | The signup form's target, for a logged-out visitor. |
| `admin_post_nlnws_signup` | action | `includes/class-public.php` | The same, for a logged-in visitor. |
| `admin_post_nopriv_nlnws_unsubscribe` | action | `includes/class-public.php` | The unsubscribe link, and RFC 8058's one-click POST to it. GET shows a button; only POST unsubscribes. |
| `admin_post_nlnws_unsubscribe` | action | `includes/class-public.php` | The same, for a logged-in visitor. |
| `nlfrm_submission` | action, 3 args | `includes/class-forms-bridge.php` | Nolimit Forms' announcement of an accepted, non-spam submission. Acts only for a form the site owner mapped to a list, and only when `NLFRM_VERSION` is 0.1.7 or later. The person becomes pending and is mailed a confirmation. |
| `admin_post_nlnws_subscriber_action` | action | `includes/class-subscribers-screen.php` | Unsubscribe, suppress, lift suppression, or delete one person. |
| `admin_post_nlnws_subscriber_add` | action | `includes/class-subscribers-screen.php` | Adds one person by hand, as `subscribed` with no `confirmed_at`. |
| `nlnws_t_paste_<user id>` | transient, 15 minutes | A refused template paste and the reasons, so the box on the Templates tab can be filled in again. Only the administrator who pasted it sees it. |
| `nlnws_purge_pending` | action | `includes/class-plugin.php` | The daily purge of signups nobody confirmed in 30 days. Scheduled on load, cancelled on deactivate and on uninstall. |
| `cron_schedules` | filter | `includes/class-send.php` | Adds a one-minute interval, `nlnws_minute`, because a mailing cannot wait an hour per batch. |
| `nlnws_send_tick` | action | `includes/class-send.php` | The one-minute tick that starts due scheduled sends and mails one batch of the oldest send in progress. Scheduled on load, cancelled on deactivate and on uninstall. |
| `init` | action | `includes/class-issues.php` | Registers the issue post type, and flushes the rewrite rules once per plugin version. |
| `plugin_action_links_<basename>` | filter | `includes/class-admin.php` | The "Newsletters" link on the Plugins screen. |
| `plugins_api` | filter, 3 args | `includes/class-updater.php` | The "View details" panel. Guarded on `$args->slug`. |
| `plugins_loaded` | action | `includes/class-plugin.php` | Everything else is registered from here. |
| `update_plugins_<hostname>` | filter, 3 args | `includes/class-updater.php` | Offers this plugin's own updates, and only its own: see below. |
| `upgrader_process_complete` | action, 0 args | `includes/class-updater.php` | Forgets the cached feed after any update runs. |
| `wp_privacy_personal_data_erasers` | filter | `includes/class-privacy.php` | Deletes a person's address and consent rows on an erasure request. |
| `wp_privacy_personal_data_exporters` | filter | `includes/class-privacy.php` | Exports everything held about an address, with the consent dates. |
## The family's shared hooks
These belong to the Nolimit family, not to this plugin.
| 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` |
<!-- nlnws:hooks:end -->
Shipping a file is not owning its hooks. `includes/shared-menu.php` is
byte-identical across the family and frozen by SHA256; whichever copy loads
first declares the functions, so on a real site the three `wpmgr_*` filters
are applied by *a* copy of that file, probably another plugin's.
### 🔴 `update_plugins_<hostname>` is shared by every plugin on that host
The filter name is built from the hostname, not the repository, so every plugin
whose `Update URI` is on gitea.nolimit.dk lands on the same one and
WordPress runs every callback for every one of them. Answering unconditionally
offers this plugin's release to a neighbour. The fix has two halves: register with three accepted arguments,
**and** return early when `$plugin_file` is not this plugin. Leave the count at
two and the argument never arrives, so the guard is dead code. Both halves are
covered by a test that fails if either is removed.
### The public endpoints
`admin-post.php?action=nlnws_confirm&t=<token>` and
`admin-post.php?action=nlnws_unsubscribe&t=<token>[&l=<list id>]` are the two
links in every mail this plugin sends. They need no login and no nonce, because
the 32-hex-character token is the authority and it can only act on its owner's
own subscription. **A GET never changes anything**: it draws a page with a
button, and only a POST acts. A mail scanner fetches every link in a message,
and a GET that confirmed would let it confirm people who never asked. An
unsubscribe POST is also what RFC 8058's `List-Unsubscribe-Post` sends, so the
same URL serves the mail client's own button. An unknown, malformed or
suppressed token answers 404 with a page that says nothing about which.
The signup form (`admin-post.php?action=nlnws_signup`) answers the same
whether the address is new, already subscribed, suppressed or throttled, so it
cannot be used to learn who is on a list. It accepts only lists marked public,
whatever the request names, and mails one confirmation per address per hour.
### Shortcode
`[nolimit_news_signup lists="slug,slug" name="yes" button="Subscribe"]` draws the
signup form. All attributes are optional: with no `lists` every public list is
offered, one list becomes a hidden field, and boxes are never pre-ticked. The
notice after a signup is chosen by `?nlnws=sent|bad`; any other value shows
nothing.
### Blocks for the website
`nolimit-news/signup` draws the form `[nolimit_news_signup]` draws, from the
same code, with attributes `lists` (slugs; none means every public list),
`name` (ask for a name) and `button`. `nolimit-news/archive` lists published
issues, newest first, with `count` (1 to 50), `dates` and `excerpt`. Both are
dynamic and keep only their attributes. A draft or scheduled issue is never
listed, whatever the block is told, and the editor script that registers them
is not loaded on an issue's own screen. No hook is added.
### Signup from Nolimit Forms
`NLNWS_Forms_Bridge` listens to `nlfrm_submission` (`$values`, `$form`,
`$entry_id`).
It never acts unless the site owner has mapped that form to a list, Nolimit
Forms is loaded and `NLFRM_VERSION` is 0.1.7 or later (the hook is not fired
for spam-filed entries from that version; earlier ones would hand a bot's
address to a mailing list). The email comes from the mapped field, or the
form's first `email` field. When a consent field is mapped its value must be
`1`. The address goes through `NLNWS_Subscribers::subscribe()` with source
`form`, so suppression, unsubscribed people and double opt-in behave exactly
as they do for the signup form, and a mapping may point at a list the signup
form does not offer. Nothing is stored beyond what a signup stores, and
nothing is added to the form's own entry.
### Who may use the screens
Every screen and every `admin_post_nlnws_*` handler requires `manage_options`
(`NLNWS_Admin::CAP`), checked first and answered with a 403, and then a nonce.
A logged-in user without the capability who posts to `admin-post.php` gets the
403 and nothing is read or written; The test suite posts each handler as a
subscriber-role user and asserts that. The CSV export is a subscriber list, so
it is behind the same gate rather than being a lighter one for a read.
## Options and storage
Everything here is removed by `uninstall.php` on **delete**, not on deactivate:
switching the plugin off for ten seconds must not destroy a mailing list. The
subscriber tables hold personal data with no other owner, so a deleted plugin
takes them with it. The test suite runs `uninstall.php` and asserts every
row of this table is gone.
| Name | Kind | Holds |
| --- | --- | --- |
| `{prefix}nlnws_lists` | table | The lists: name, slug, description, whether the signup form offers it. |
| `{prefix}nlnws_subscribers` | table | One row per address, stored lower-case: name, the secret token in that person's confirm and unsubscribe links, and a suppression reason. |
| `{prefix}nlnws_memberships` | table | One row per person per list: status (`pending`, `subscribed`, `unsubscribed`), how they joined (`form`, `admin`, `import`), when they asked, when they confirmed, when they left. |
| `{prefix}nlnws_opens` | table | Only when open counting is on: one row per person per issue with the two ids, the first and last time an image was loaded, and how many times. Deleted with the issue, with the person, and on uninstall. |
| `nlnws_db_version` | option | The schema version the tables were last built at. |
| `nlnws_rewrite_version` | option | The plugin version the rewrite rules were last flushed at, so the `/newsletters/` archive works after an update without a visit to Settings → Permalinks. |
| `nlnws_settings` | option | Brand colour, logo URL and the postal address printed in every issue's footer. |
| `nlnws_forms` | option | Which Nolimit Forms forms sign people up: form id => list id, email field id and consent field id (the last two may be empty). |
| `nlnws_templates` | option | Email templates pasted in by an admin: slug => HTML. Each is validated on the way in and again when read. |
| `_nlnws_template` | post meta on an issue | The slug of the template this issue is mailed in. Empty or unknown means the site default in `nlnws_settings`. |
| `nlnws_send_lock` | option | The time a tick took the send lock, so two ticks never mail at once. A lock older than five minutes is taken over. Absent between ticks. |
| `nlnws_send_tick` | WP-Cron event, every minute | Drives all sending and scheduled starts. |
| `_nlnws_state`, `_nlnws_lists`, `_nlnws_send_at`, `_nlnws_cursor`, `_nlnws_total`, `_nlnws_sent`, `_nlnws_failed`, `_nlnws_started`, `_nlnws_finished`, `_nlnws_last_batch`, `_nlnws_error` | post meta on an issue | The send state: `''`, `scheduled`, `sending`, `stopped` or `sent`; the chosen lists; the start time; the id of the last subscriber mailed; the counts; the times; and the last mailer error. There is no per-message record. |
| `nlnws_auto_update_seeded` | option | That the one-time auto-update offer was made. |
| `nlnws_release_feed` | transient | The cached release feed. |
| `nlnws_t_<32 hex>` | transient, one hour | "This address was just mailed a confirmation". The name is a keyed hash of the address, so the address is not written to the options table. |
| `nlnws_purge_pending` | WP-Cron event, daily | Deletes a pending membership older than 30 days, and the person too when nothing else is left and they are not suppressed. |
The admin screens store nothing of their own: they read and write the tables
above. A notice after a save is carried in the redirect's query string as
a short code and printed from a fixed list, so a crafted URL cannot put words
on the screen.
No IP address, user agent or referrer is stored anywhere, and no click is
recorded. **Open counting is an option, off by default** (`track_opens` in
`nlnws_settings`). When it is on, each mail to a person carries one 1x1 image at
`admin-post.php?action=nlnws_open&i=<issue>&t=<token>`, and loading it writes
that person's row in `nlnws_opens`. Nothing is read from the request except the
issue and the token, so there is nothing else to store, and nothing leaves the
site. Turning it off stops recording at once, for mail already sent too, and
removes the pixel from every mail after it. A preview, a test send and the
plain-text twin have no person and so no pixel. The number is an estimate: some
mail programs load images for the reader and others never do. The suggested
privacy text and the signup form's fine print change with the setting.
`confirmed_at` is set only when the person followed the link in the
confirmation mail; a row with `source = import` and no `confirmed_at` is
visibly one nobody confirmed, which is the difference between consent you can
show and consent you are asserting.
### Import
Subscribers → Import reads a CSV export. Only the address and the name are
read (`First Name` and `Last Name` are joined; Danish Mailchimp headers, our own
export, and comma, semicolon or tab separators are understood). The IP address,
location and rating columns Mailchimp adds are not read and not stored, and the
file itself is never kept. The screen makes the person importing tick that the
people agreed to receive the newsletter. Three kinds of file:
* **Subscribed**: joined as `subscribed`, `source = import`, no `confirmed_at`.
Somebody who unsubscribed here, or whose address is suppressed, is skipped and
counted; an import can never undo a person's own decision.
* **Unsubscribed**: recorded as already unsubscribed, so a later subscribed file
cannot bring them back. An existing membership is never changed by it.
* **Cleaned**: the addresses are suppressed as `bounced`, on no list.
A request works for 20 seconds and then stops and says which row to start at;
the file can be uploaded again from there, and rows already in are counted as
"already there". Files over 10 MB are refused. Separately, "Ask the imported
people" mails each imported, unconfirmed person once (50 per click, one mail per
address per hour) with their own confirm link. They stay subscribed whether they
answer or not; the link only stamps `confirmed_at`. The place it has reached is
the option `nlnws_reconfirm_cursor`, removed on uninstall and when the last
batch is done.
A signup nobody confirms is deleted after 30 days; an address that is suppressed is
never deleted by it, or it could be mailed again.
### Sending
An issue is `''` until sent, then `scheduled` (waiting for `_nlnws_send_at`),
`sending`, `stopped` (by hand, or by the mailer failing the first five
messages in a row) or `sent`. A send mails the people who are `subscribed` to
any chosen list and not suppressed, once each however many of the lists they
are on, in id order from the cursor, so a resume never mails anybody twice. The
audience is read each batch: somebody who unsubscribes after the send began is
not mailed. A failed address is counted and passed over, never retried. Each
message goes through `wp_mail()` as HTML with a plain-text alternative and
carries `List-Unsubscribe` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
for that person. When the last batch is done the issue is published, which puts
it in the public archive. WP-Cron on a quiet site fires only when somebody
visits, so a scheduled time is "not before", never "at".
### The email template
A template is one HTML file: a header comment (`Template:` is required;
`Description:`, `Text:`, `Muted:`, `Line:` and `Font:` are optional), then a
complete `<html>` document that uses `{{tokens}}`. `{{{content}}}` (three
braces, unescaped) is the issue's body and `{{unsubscribe_url}}` is required.
Sections `{{#token}}...{{/token}}` show when the value is not empty and
`{{^token}}...{{/token}}` when it is. The full grammar, every token and a
working example are in `templates/AUTHORING.md`, which is written to be handed
to an AI that designs the template; a test keeps that guide and the token list
in step.
Templates come from `templates/*.html` in the plugin, then
`nolimit-news/*.html` in the parent and child theme, then the pasted ones in
`nlnws_templates`; a later source with the same slug replaces an earlier one.
**A template that fails its checks is skipped and reported, never sent**: no
script, iframe, form, link, `@import`, `javascript:`, PHP or `on*=` handler, no
token that does not exist, no unclosed section, at most 40 000 bytes. The
refusal is worded so that an AI can fix what it names. The shipped files are
`default` and `minimal`; the token set may grow, and a token is never renamed.
### The composer
Writing an issue is the block editor, narrowed. On an `nlnws_issue` screen only
the blocks that survive a mail client are offered: paragraph, heading, list,
quote, image, separator, and two of our own, `nolimit-news/product` and
`nolimit-news/button`. Everything else is hidden there and untouched on every
other post type. Both blocks are dynamic (the editor stores only their
attributes, never markup), so a product's price and name are read when the mail
is drawn and a template change reaches issues already written.
`nolimit-news/product` shows a live WooCommerce product when `productId` names a
published one, and otherwise the fields typed in by hand, so it works on a site
without a shop; a draft, trashed or missing product draws nothing rather than a
broken card. The button takes the issue's brand colour and picks black or white
text by contrast. Both draw single-line tables with inline styles, which is
what mail clients keep.
The editor's product list is `GET /wp-json/nolimit-news/v1/products?search=`,
which needs `manage_options` (`NLNWS_Admin::CAP`) and answers 401 or 403
otherwise. It returns `woocommerce` (whether a shop is active) and at most 12
published products. Dragging a product from that list into the text, or clicking
it, inserts the block; image files are dropped onto the editor as on any post.
No hook is added for any of this.
### The issue post type
`nlnws_issue` is a public post type: a newsletter is a post, with a permalink,
revisions and the block editor. Its archive is at `/newsletters/` and each
issue at `/newsletters/<slug>/`. **A draft is never public; a published issue
is.** Every primitive capability of the type is `manage_options`, so an editor
who can write posts cannot write, publish or delete an issue; reading a
published one needs only `read`. Issues appear as a submenu of the Newsletters
page. The send state above is stored on it as protected post meta.
Deleting the plugin deletes every issue, trashed ones and their revisions
included. It leaves an ordinary post alone.
## Constants
| Name | Meaning |
| --- | --- |
| `NLNWS_VERSION` | The version, equal to the header's. |
| `NLNWS_COLOUR` | The card colour, `#4a4fc4`, measured 6.55:1 against white. It exists three times, here, as `--nlnws-accent` in `assets/css/admin.css` and as the tile fill in `assets/icon.svg`; the release checks assert all three agree. |
| `NLNWS_PLUGIN_FILE`, `NLNWS_PLUGIN_DIR`, `NLNWS_PLUGIN_URL`, `NLNWS_PLUGIN_BASENAME` | Paths, for this plugin's own use. |
## What a consumer must not do
Read this plugin's tables directly, once they exist: the schema is not part of
the contract and will change. Call a class of ours; only the hooks above are
promises.