Chapter 8 of 8. Two laws govern a UK newsletter, and only one of them decides whether you are allowed to press send. Prerequisites: identifiers, hashed IPs and consent and the unsubscribe and erasure flow, so you already have hashed identifiers at rest and a working exit path.
Search for "UK GDPR newsletter consent" and you get marketing-agency listicles. Search for "PECR" and you get law-firm explainers written for compliance officers. Neither tells you which column to add or what your event log is allowed to contain. The ICO publishes all of it. What nobody publishes is the join between the guidance and a schema.
So this chapter joins it up. Everything below maps a specific piece of ICO guidance to a specific line of the newsletter backend running on this site: a MySQL 8 schema, six PHP classes, and a cron job on a shared cPanel host.
PECR decides whether you may send. UK GDPR decides how you may hold it.
Two different laws, two different jobs. The confusion is that both of them are satisfied by the same form.
PECR is the gate on the send. The ICO's guidance on electronic mail marketing sets out regulation 22: you must not send marketing email to an individual unless they have specifically consented, or the soft opt-in applies. Soft opt-in is narrow. It covers an existing customer who bought, or negotiated to buy, a similar product or service, and only where you gave them a simple way to opt out both at collection and in every message since. A content newsletter with no prior customer relationship cannot use it. That leaves exactly one lawful route: specific consent.
UK GDPR is the contract on the row. The moment you store an email address you are holding personal data, and everything you store next to it comes with you: the status, the timestamps, the source tag, the hashed IP. The consent PECR demands is also your Article 6(1)(a) lawful basis, and it has to meet the UK GDPR standard of valid consent to count.
PECR asks "may I mail this person?" UK GDPR asks "may I hold this row, and for how long?"
What PECR regulation 22 puts in your outbound headers
Regulation 22 puts two requirements in the mailer and none in the database. The ICO guidance states them plainly: a marketing message must not disguise or conceal the sender's identity, and it must provide a valid contact address so the recipient can opt out.
That is the entire justification for the header block in Email.php:
$headers = sprintf("From: %s <%s>\r\n", self::encodeName($name), $from);
$headers .= sprintf("Reply-To: %s\r\n", $from);
$headers .= sprintf("Return-Path: %s\r\n", $from);
$headers .= sprintf("List-Unsubscribe: <%s>\r\n", $unsubscribeUrl);
$headers .= "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n";
A real From, and an unsubscribe route the recipient can take without replying to you. The List-Unsubscribe value is a per-subscriber URL carrying a 64-hex token; the plaintext exists only in that URL and only sha256(plaintext) is at rest. List-Unsubscribe-Post promotes it to a one-click button in Gmail, Outlook and Apple Mail, which is the strongest possible reading of "a simple way to opt out". How those headers survive the multipart MIME assembly is chapter 5's problem; regulation 22 only cares that they arrive.
Valid consent, and the one line of SQL that records it
The ICO's definition of valid consent is the standard your flow has to clear. Consent must be freely given, specific, informed, and an unambiguous indication of the person's wishes by statement or clear affirmative action. Silence, pre-ticked boxes and inactivity do not count. Article 7 adds a condition the regulation states and most implementations never encode: you must keep records to demonstrate consent.
"Keep records to demonstrate consent" is a column.
consented_at DATETIME NULL,
And it is written exactly once, in confirm.php, in the same statement that destroys the token that proved the click:
$upd = $pdo->prepare(
'UPDATE subscribers
SET status = ?,
consented_at = NOW(),
confirm_token_hash = NULL,
confirm_expires_at = NULL
WHERE id = ?'
);
$upd->execute(['confirmed', $id]);
That one UPDATE carries the whole compliance argument, and it does it twice.
First, signup alone writes no consent timestamp. Inserting a pending row gives you a 60-minute token and nothing else. consented_at is written only when someone holding a valid, unexpired token completes the confirm. The double opt-in is what makes the record defensible: you can show the timestamp and the fact that a secret sent to that mailbox came back.
Second, confirm.php splits GET from POST. The GET renders an interstitial with a button and touches the database not at all; the mutation happens exclusively on POST. Mail scanners and link-preview bots fetch every URL in an inbound message, so a GET that stamped consented_at would leave you holding a consent record that is really a machine's prefetch. "Clear affirmative action" means a person pressed something, and the POST is the thing that makes that true. Keeping tokens out of browser history and Referer headers is a genuine benefit of the split, but it is the smaller one.
Consent logging: what the events table stores, and what leaked in anyway
The audit trail is a second table, and its shape is a privacy decision as much as an observability one.
CREATE TABLE IF NOT EXISTS events (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
subscriber_id INT UNSIGNED NULL,
kind ENUM(
'signup', 'confirm_sent', 'confirmed', 'unsubscribed',
'purged', 'rate_limited', 'bot_detected'
) NOT NULL,
at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
meta JSON NULL,
PRIMARY KEY (id),
KEY idx_kind_at (kind, at),
KEY idx_subscriber (subscriber_id),
CONSTRAINT fk_events_subscriber
FOREIGN KEY (subscriber_id) REFERENCES subscribers(id)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
The events table carries neither an email column nor an IP column. Correlation between an event and a person runs through subscriber_id, an integer foreign key, and ON DELETE SET NULL means the audit rows survive a subscriber's hard-delete with subscriber_id set to NULL. You can still count how many people confirmed in March without holding anyone from March.
The docblock at the top of Logger.php is wrong. It says forensic correlation "uses sha256(email) only (never the plaintext email, never tokens)". The real signature is Logger::event(string $kind, ?int $subscriberId = null, array $meta = []), which takes no email parameter, and no call site passes a digest either. The join is the integer id, and the docblock describes a mechanism the code has never had.
Where the description actually breaks is meta. On the 429 path, subscribe.php writes a hashed IP straight into it:
logSafely('rate_limited', null, [
'ip_hash' => hash('sha256', $ip . '|' . ($_ENV['HASH_SALT'] ?? '')),
'limit' => $throttle['limit'],
]);
The schema documents the shape two lines above the column, verbatim: rate_limited → {"ip_hash":"abc…", "count":21, "window":"60s"}. So the event log does hold an identifier, and by the argument in the next section that identifier is personal data. Now look at where it sits. The second argument to that call is null, so a rate_limited row has no subscriber_id at all: the request was refused before any subscriber came into it. ON DELETE SET NULL is not a control on this path, because the only column it can touch is already empty and the identifier is inside a JSON blob that no foreign key reaches into. The ip_hash stays in events.meta until the 12-month events purge collects the row on age, and nothing in the deletion path knows the digest is there.
The same failure shows up in the privacy policy was correct, the article body wasn't and in the identifiers I didn't know I'd published: a compliance document and a shipped artefact saying different things, with nobody diffing them. Here the drift runs the dangerous way round, and the comment is the safer of the two.
One deliberate omission is still worth copying. A confirm POST with a bad token writes no event row at all, because an attacker probing id and t could otherwise count rows to learn which guesses landed closer. Row counts are an information channel. Be exact about how quiet this is: the bad-token, expired-token, wrong-status and no-such-row branches in confirm.php all call badLink(), and badLink() renders an error page without writing anywhere. Only the outer catch (Throwable $e) reaches error_log. A failed confirm leaves no event row and no log line, which is the shape to copy: log what a subject access request needs, and let nothing else earn a row.
The hashed IP is still personal data
I shipped this field believing the hash took it out of scope. It does not, and the mistake costs you the lawful basis rather than the data.
Throttle.php names each rate-limit file <sha256(ip|salt)>.json and finds today's flooder by recomputing yesterday's digest. Recital 30 names IP addresses first in its list of online identifiers, and the ICO's page on identifiers and related factors is what makes that recomputation legally load-bearing rather than merely convenient. The base what is personal data page closes the escape hatch behind it: pseudonymised data is still personal data where the individual remains identifiable. Pseudonymisation buys you a security control. It does not shrink the scope of the regulation.
Hashing does not anonymise. Here is what the backend actually does:
$salt = (string) ($_ENV['HASH_SALT'] ?? '');
$ipHash = $ip !== '' ? hash('sha256', $ip . '|' . $salt) : null;
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$uaHash = $ua !== '' ? hash('sha256', $ua . '|' . $salt) : null;
Those four lines are one-way against reversal and not one-way against recognition. PHP's hash() is deterministic over a named public algorithm, so feeding the same address and the same salt back in tomorrow returns the same digest. That determinism is the entire reason the value is worth storing, and it is also the reason the value never leaves the regulation. Anything that reliably picks the same visitor out of a crowd across days is doing an identifier's job.
That has two consequences in this codebase. The field needs its own lawful basis, and this site declares Article 6(1)(f) legitimate interest for abuse triage while reserving 6(1)(a) consent for the sends. The field also needs the salt, because a digest is only a secret while its input is. An IPv4 address is 32 bits wide and sha256 is a public function, so anyone holding an unsalted digest can hash candidate addresses until one matches. HASH_SALT lives in a .env file outside the webroot at mode 600, and the environment variable name is the only part of it that ever appears in public.
Storage limitation is a cron job, not a paragraph
cron/purge.php runs on a schedule: 0 3 * * * for the full pass, 0 * * * * for the pending pass. That crontab is the whole of my storage-limitation policy. The ICO's storage limitation guidance asks that you not keep personal data for longer than you need it, and that standard retention periods be set out in a policy wherever possible. Most sites answer the second half by writing a table into the privacy policy. That documents the intent and enforces nothing. These are mine, and they execute:
DELETE FROM subscribers
WHERE status = 'pending'
AND confirm_expires_at IS NOT NULL
AND confirm_expires_at < (NOW() - INTERVAL 1 HOUR);
DELETE FROM subscribers
WHERE status = 'unsubscribed'
AND unsubscribed_at IS NOT NULL
AND unsubscribed_at < (NOW() - INTERVAL 30 DAY);
DELETE FROM events
WHERE kind <> 'bot_detected'
AND at < (NOW() - INTERVAL 12 MONTH);
DELETE FROM events
WHERE kind = 'bot_detected'
AND at < (NOW() - INTERVAL 90 DAY);
Four DELETE statements. The retention table published at /privacy/ has five rows, and the arithmetic of that gap is the lesson. Four of the rows map straight onto a statement: expired pending, unsubscribed at 30 days, general events at 12 months, bot_detected at 90 days. The fifth row reads "Confirmed subscription: kept while you are subscribed", and it has no DELETE behind it by design. An active subscriber is data you still need, so the row promises no deletion and no code is missing. A retention row may promise indefinite retention while a purpose holds, but every row that promises a deletion needs code that performs it.
Somebody who signs up and never clicks confirm is hard-deleted about two hours later: the 60-minute token TTL plus a one-hour grace window in case they are genuinely mid-click. Somebody who unsubscribes is soft-deleted immediately and hard-deleted at 30 days. The cron user holds SELECT, UPDATE, DELETE and no INSERT, the app user holds SELECT, INSERT, UPDATE and no DELETE, and neither of them holds CREATE, DROP or ALTER.
Withdrawal has to be easy, and available at any time
The consent guidance lists the right to withdraw consent easily and at any time among the conditions Article 7 imposes. The right to erasure guidance then connects withdrawal to deletion: withdrawing consent is itself an erasure ground, and an objection to direct-marketing processing triggers the same obligation. You have one month to act on either. Chapter 7 builds the flow that does it; this section is the argument for why its shape is not optional.
Count the acts on each side. Giving consent here takes three: submit the form, open the mail and click the link, then press the confirm button on the interstitial the GET renders. Withdrawing takes one. The List-Unsubscribe-Post header lets the mail client fire the POST itself, so there is no login, no account, and no interstitial rendered at all. That POST flips status to unsubscribed, stamps unsubscribed_at, and the 30-day purge finishes the job. Whatever else is arguable about the flow, the exit is not harder than the entrance.
The soft-delete keeps a full row, including the address and the hashed identifiers, for a month after someone leaves. I took that retention decision so a complaint about mail arriving after an unsubscribe can be answered with evidence, and the published policy says the same thing in plainer words: the record is "kept 30 days so we can confirm you unsubscribed if asked". It survives audit for one reason: the policy says 30 days and the SQL says 30 days.
Audit your own newsletter in four greps
- Find the send gate
Grep for the code path that dispatches marketing mail and confirm nothing reaches it without a completed confirm step. If you can be added to the list without a token round-trip, you have no PECR consent, only an address.
- Find the consent timestamp
There must be a column, it must be written by a POST, and it must be written after token verification. If you cannot name the column, you cannot demonstrate consent under Article 7.
- Grep the log writer for PII
Search every call site of your event logger for an email or a token. Then search the free-form payloads separately, because that is where mine hides a hashed IP. An identifier inside a JSON column is still an identifier, and a foreign key that nulls on delete does not reach into it.
- Diff the retention table against the cron
Every published retention row that promises a deletion needs a matching
DELETE, and everyDELETEneeds a matching row. A row that promises retention for as long as a purpose holds needs no code, but you should be able to name the purpose out loud. Anything left over is an undocumented deletion or an undelivered promise.
The transferable rule
A privacy policy is a set of claims about a codebase. Every sentence in it should map to something you can grep: a column, a DELETE, a header, an ON DELETE SET NULL. A sentence with no counterpart in the code is a hope. And when a comment in the code has no counterpart in the behaviour, as Logger.php and Throttle.php both currently do here, the drift is silent and it always resolves in whichever direction nobody checked.
The compliance work lives in the schema and the cron. Write the policy from the code, then check the code against the policy on a schedule, exactly as you would any other test you do not have.
Diff your privacy claims against your code
Run this chapter's four-grep audit against a project of yours that sends mail or stores identifiers, then go a step further: turn every sentence of its privacy text into a claims-to-code table and fix the worst drift you find, in whichever direction it runs.
Expected behaviour
- The send gate located and verified: nothing reaches the dispatch path without a completed confirmation step
- The consent timestamp named as a specific column, written by a POST after token verification
- Every log-writer call site and every free-form payload grepped for emails, tokens, raw IPs and other identifiers, with findings listed
- A table mapping each privacy-policy sentence to a greppable artefact (a column, a DELETE, a header, a constraint), with unmapped sentences flagged
- At least one drift fixed and committed, whether a code change or a policy correction
PROVE IT Present the completed table plus the diff of the fix, and run one grep from the audit live, showing the artefact it maps to.
Which rule decides whether you may lawfully send the newsletter to a UK individual at all?
Which lawful basis does the site declare for storing the hashed IP and user agent?
The published retention table has five rows and the purge cron has four DELETE statements. Why is that not a gap?
Show answer
Four rows each map to a statement: expired pending, unsubscribed at 30 days, general events at 12 months and bot_detected at 90 days. The fifth row promises to keep confirmed subscriptions while you are subscribed, which is retention while a purpose holds, so no deletion is promised and no code is missing. The rule is that every row promising a deletion needs code that performs it.
↺ re-read: “Storage limitation is a cron job, not a paragraph”