323 lines
22 KiB
Markdown
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.
|