WebBlocks Campaigns
Requirements
Documented package version: 0.7.0. WebBlocks CMS ^1.45.5; PHP >=8.3.
A WebBlocks CMS plugin for sending a templated email to your own customer list, with the name, address and other fields filled in per recipient.
Consent, and why suppression is separate
Consent is recorded per channel. Marketing and operational messages are different things: someone who opts out of advertising must still receive "your invoice is due", so one flag would force a choice between spamming people who opted out and withholding messages they need.
Each contact records why it may be written to — the basis, where it came from, when, and for explicit consent the exact wording agreed to. An import records the same against itself, so "why is this address here" has an answer a year later.
The suppression register is a table of its own, keyed by address rather than by contact, and no import ever touches it. That is what makes this sequence safe: someone unsubscribes, the operator later clears the list and re-imports the original spreadsheet, and the unsubscribed person does not come back. Releasing an address is the only way out, and it is deliberate.
Consent is only ever raised by an import, never lowered — a withdrawal survives a re-import, and an existing-customer import does not downgrade someone already on explicit consent.
Importing
Upload, map, preview, commit. Nothing is written until the last step, and every rejected row is reported with its line number in the source file so the operator fixes the spreadsheet rather than guessing at what was dropped.
Semicolon-separated files and a UTF-8 BOM are read without complaint, because that is what German and Turkish spreadsheet exports produce. A column mapping is suggested and never applied on its own: a guess that maps names onto the email field is worse than no guess, because it looks like it worked.
A blank cell is an absence, not a value. A re-import fills gaps and does not erase what is already there.
Writing a template
{{ first_name }} takes the value. {{ first_name | Kunde }} says what appears when it is missing — which is the difference between "Hallo Ada" and "Hallo ,", the way a mail merge is remembered.
Merged values are escaped in HTML; the template is not. The template is yours, the values came from a spreadsheet.
Two things are checked before you can send a test:
- An unknown field blocks.
{{ firstname }}where the field isfirst_namerenders empty for every recipient and looks perfectly fine in the editor. Available fields are derived from the contacts you actually imported, so the typo is catchable. - A missing fallback warns. It is a judgement, not an error — "Hallo" with no name is sometimes exactly right.
The preview renders against a real contact rather than invented data, because invented data is always well-formed and therefore proves nothing. A test send delivers the already-rendered strings, so what arrives is what a campaign would send, prefixed [TEST].
Sending
A campaign pairs a template with a list and declares what kind of message it is:
- Commercial is advertising. It reaches only contacts whose consent permits advertising, and its template must contain
{{ unsubscribe_url }}— queuing is refused otherwise. - Operational concerns an existing contract. It is not advertising, so a marketing opt-out does not withhold it, and it needs no unsubscribe link.
Queuing does not send. It materializes the recipient set — a send takes hours, and deriving it from the list mid-run would let an edit silently change who is written to. Then cron does the work:
php artisan webblocks-campaigns:dispatch
Each run sends one batch per campaign, bounded by the per-site batch size and hourly cap. That bound is the point: a bulk send poured through a shared host's SMTP is how a sending domain gets blocked, and that takes months to undo.
The snapshot is not trusted. Suppression and consent are re-checked for every recipient at the moment of sending, so someone who unsubscribes an hour into a two-hour run does not receive the rest of it. Pausing is re-read between batches and stops the run immediately.
Unsubscribing
Every commercial message carries a link built from a per-recipient token, so an opt-out records which campaign prompted it. The List-Unsubscribe header is set as well, since its absence is one of the signals that sorts bulk mail into spam.
The GET shows a confirmation page and the POST does the work — mail clients and security appliances follow links unprompted, so acting on GET would unsubscribe people who never clicked. A second click says "you are unsubscribed" rather than failing.
List-Unsubscribe-Post (one-click) is deliberately not advertised: it asks the client to POST without a CSRF token, which the CMS rejects with a 419. Claiming a capability that fails is worse than not claiming it.
An unsubscribe writes two records — a suppression, which is absolute and survives a re-import, and a consent withdrawal on the contact, which is what an operator sees and what evidences that the site stopped when asked.
Reading the result
The send log lists every recipient with its status and the reason behind it, filterable. Above it, skip and failure reasons are grouped: "412 skipped" is not actionable, while "390 suppressed, 22 consent withdrawn" is either reassurance or a task.
Failures can be retried; skips cannot. A skip was a decision, and retrying one would either repeat it or override it — the second is how someone who opted out receives mail again. Retried recipients go through the same consent and suppression checks as everyone else.
Settings
Per site: batch size, hourly cap, sender name and address, reply-to. The caps are why batching exists — a bulk send poured through a shared host's SMTP is how a sending domain gets blocked, and that takes months to undo. The screen also says plainly when outbound mail is unusable, so a campaign full of skips is not mistaken for a bug.
Bounces
Rejections are classified three ways, and only one of them suppresses:
The third row is the point of the whole feature. Those rejections arrive with permanent 5xx codes and look identical to a dead mailbox, but they are about your sending, not the recipient. Suppressing on them would delete valid customers because of a reputation or configuration fault — silently, one campaign at a time. Ambiguous cases resolve towards not suppressing: keeping a dead address costs one bounce, suppressing a live one costs a customer.
Classification happens at send time from the SMTP failure, so it needs no webhook, no return-path mailbox and no provider integration — it works with whatever transport the site already uses.
Every rejection is kept as history, not just as a suppression. A suppression cannot say why or how often, and a run of receiver refusals is invisible without it. The Delivery problems screen warns when that pattern appears and says what to check.
Install
composer plugin:build
Then install the artifact through System → Plugins in the CMS admin. Plugins install disabled; enable explicitly after reviewing.
Conventions
handle webblocks-campaigns
settings namespace webblocks_campaigns
database prefix webblocks_campaigns_
admin routes /webadmin/plugins/webblocks-campaigns
public routes /plugins/webblocks-campaigns
route names webblocks.plugins.webblocks_campaigns.*
permissions webblocks-campaigns.view, .manage, .send, .settings
send is separate from manage on purpose: editing a draft and releasing it to two thousand people are different acts, and an organisation should be able to grant one without the other.
Collect subscribers with double opt-in
Add the sign-up block to a page and choose the target list, heading, intro, consent wording and optional name field. A request creates a pending subscription. The recipient must use the emailed confirmation link and confirm before the address becomes sendable. Confirmation links expire after seven days.
The plugin records the consent wording shown at signup. A confirmation can lift an unsubscribe or hard bounce; it does not override a spam complaint or manual operator block. Existing contact names are preserved.