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

22 KiB

The surface Nolimit News exposes

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.

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

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.