The recipient snapshot: removals, erasure requests and re-sends

The recipient list is a copy of your users, not a query against them — how to actually remove an address, what deleting a user does not do, and why Stop re-mails everybody.

9 min readUpdated 6 August 2026recipients, erasure, privacy, opt-out, resend

How sending works tells you there is no unsubscribe link and that you have to process opt-outs yourself. This page is the how, and it starts with the one fact that makes the rest of it counter-intuitive:

A campaign's recipient list is a snapshot, not a query. When you tick people in Add Targets, their details are copied onto the campaign row and stored there as JSON. From that moment the campaign has its own private copy of those addresses, and nothing keeps it in step with your user table.

What the send job actually reads

The hourly job loads the campaign, parses the targets column into an array, and for each entry still PENDING calls the mailer with target.email, the campaign's subject and the template's HTML. That is the whole path.

There is no user lookup at send time. No join, no findByPk, no check that the account still exists, is still verified, is not blocked, and no consultation of any notification preference.

Removing somebody from the platform — /admin/crm/user, or any other route that takes their row out of the user table — has no effect on campaigns that already hold them. Their address is sitting in each of those campaigns' JSON, and the send job will mail it.

If that campaign is ACTIVE with pending recipients, or is later restarted, the deleted user receives the email.

The snapshot is also more than an address. The picker copies the row the CRM user list endpoint returned, which is the whole user record minus password and metadata, plus the joined role, KYC applications and their level, 2FA state and block records. So a campaign row can hold a copy of a person's profile long after the original row is gone. That is what makes this an erasure question and not just a mailing-list one.

Removing one person from a campaign

There are two write paths and they are not equivalent.

From the admin screens

Open the campaign at /admin/mailwizard/campaign/{id}. Each recipient card in the Targets panel carries a small × in its top-right corner — but only while that recipient is still PENDING. Once an address has been attempted the × disappears, because deleting the record of a send does not un-send it.

The × only edits the list held in the page. Nothing is written until you press Update Campaign in the Campaign Settings panel, and that button posts the entire list back through PUT /api/admin/mailwizard/campaign/{id} — the route whose targets field is schema-validated at 10,000 characters. On any campaign built through the picker that limit is reached at roughly fifteen recipients, so on a real audience this save is simply refused. See Building and sending a campaign.

Through the targets endpoint

Replaces the whole recipient list. No size cap, no other required fields

This is the path that actually works for erasure, and the only one that can remove a recipient who has already been SENT or FAILED.

It takes one field, targets, containing the full replacement list as a JSON string (an already-parsed array is accepted too). It does not require name, subject, speed or templateId, and it has no character limit — it validates the shape of each entry and a ceiling of 100,000 recipients, and nothing else. Because it is a full replacement, whatever you leave out is gone.

{
  "targets": "[{\"id\":\"a1…\",\"email\":\"jo@example.com\",\"status\":\"SENT\",\"attempts\":1}]"
}

The send job rewrites this same column as it works, every ten recipients. A replacement built from a copy you fetched an hour ago will overwrite the delivery statuses recorded since — recipients already delivered to go back to PENDING, and the next run emails them again.

Pause the campaign, GET the campaign, edit that list, PUT it back.

The endpoint requires edit.mailwizard.campaign and is recorded in the admin audit trail under module ADMIN_MAIL with the title Update campaign targets, so an erasure carried out this way is evidenced. See The admin audit trail.

Handling an erasure request

  1. Find every campaign holding the address. There is no search for this — targets is a single JSON column, so no screen and no filter can look inside it. The view dialog on /admin/mailwizard/campaign lists recipients failures first, then pending, then sent, and stops at 25 with a count of how many are not shown, so it cannot prove an address is absent from a long list. On anything larger, read mailwizard_campaign.targets directly and search it. Include soft-deleted campaigns if you keep them — the table is paranoid, and a deleted row still holds the JSON.

  2. Pause anything ACTIVE. The Pause button on the campaign page. Do not use Stop — see the next section.

  3. Rewrite each list without that entry, through PUT /api/admin/mailwizard/campaign/{id}/target, one campaign at a time.

  4. Add the person to your own suppression record, outside the product. See below.

  5. Restart what you paused with Start. Recipients keep the statuses they had, so nobody is mailed twice.

Deleting the campaign outright is the other option, but on its own it erases nothing. Delete on the Campaigns list is a soft delete: the row is stamped deletedAt and keeps its targets JSON exactly as it was. What changes is visibility — the campaign drops out of the list and out of the send job's query, so the mailing does stop, but every address you were asked to remove is still in the database. It is reversible too: DELETE /api/admin/mailwizard/campaign/{id}?restore=true brings the campaign back with its recipients. The Campaigns screen has no Show deleted toggle, so a deleted campaign is invisible there; GET /api/admin/mailwizard/campaign?showDeleted=true is what lists them.

Only ?force=true on that same delete removes the row for real, and with it the recipient list and the delivery history — there is no per-recipient record anywhere else to leave behind. That one is unrecoverable, and it destroys the evidence of what you sent to everyone else on that list.

Stop resets every recipient, which re-mails everyone

The two vocabularies here are different and this is where they collide. A recipient only ever holds one of three values:

Recipient status Written by Means
PENDING The picker, a /target write, STOPPED, a retry Still to be attempted
SENT The send job The mail transport accepted the message
FAILED The send job The transport refused it, or the entry had no address

There is deliberately no fourth value. Anything unrecognised is read as PENDING.

PUT /api/admin/mailwizard/campaign/{id}/status with STOPPED writes the status and rewrites every entry in the list back to PENDING. Starting the campaign again then mails the whole list from the beginning, including everyone who already received it — and including anyone you removed from the user table but not from this campaign.

Stop is a re-send button. The campaign page confirms first and names how many people would be emailed a second time. If you only want the sending to halt, use Pause.

The bulk status route, PUT /api/admin/mailwizard/campaign/status, does not perform that reset — it writes the status only. So a bulk stop behaves like a pause, which is safer but is not what the word implies.

Retry failed is the safe counterpart: it moves only FAILED entries back to PENDING, never touches a SENT one, and reactivates the campaign so the next hourly run picks them up.

The limits that decide what is possible

Limit Value Where it bites
MAX_TARGETS 100,000 entries PUT /campaign/{id}/target refuses a longer list outright
targets character cap 10,000 characters POST /campaign and PUT /campaign/{id} only — roughly fifteen picker-built recipients
MAX_TARGET_ATTEMPTS 3 Retry declines an address already attempted three times rather than hitting a known-bad mailbox a fourth time
Recipient statuses PENDING, SENT, FAILED Nothing else is ever written

The attempt cap is not arbitrary. A mailbox that no longer exists answers the same way every time, and repeatedly delivering to invalid addresses is one of the strongest negative signals a mailbox provider measures. Retry answers 409 with a count of how many are exhausted; force: true on the retry endpoint overrides it, and is the wrong thing to do unless you know the failures were transient.

An entry that is PENDING with no usable email address never reaches the mailer. The send job marks it FAILED, increments its attempt count and records lastError: "no email address", so the failed card on the campaign page says why. Retry counts those separately as unfixable and leaves them alone — no attempt can make them succeed.

Demo masking is not redaction

With NEXT_PUBLIC_DEMO_STATUS=true, recipient addresses are masked on the way out of both the campaign list and the campaign detail endpoint — partially obscured local part, partially obscured domain, recipient count preserved so progress stays truthful.

That masking happens on the response. The stored row is untouched, and the send job reads the stored row. A masked screen is not an erased address, and it is not evidence for a data-protection response.

The recipient picker is served by the CRM user list endpoint, which declares demoMask: ["items.email", "items.phone"]. On a demo install it therefore hands the picker masked addresses, and those are what get stored on the campaign. They pass validation — they still look like email addresses — and then fail at send time.

The same applies to editing: the campaign page loads the masked list and Update Campaign posts back what the page is holding.

Demo mode is for demonstrating the screens, not for preparing a real send.

There is no suppression list

Nothing in MailWizard records an opt-out, and nothing excludes an address from a future campaign. Removing somebody from one campaign removes them from that campaign and no other, and the picker will happily offer them again tomorrow.

So honouring an opt-out has two halves, and only the first is inside the product:

  • This campaign — rewrite its list through the /target endpoint.
  • Every future campaign — do not tick that person again. That obligation lives entirely in whatever record you keep outside the product: a spreadsheet, a ticket queue, a note on the user's CRM record. Keep it somewhere the person building the next campaign will actually look, because the picker will not warn them.

Put the opt-out link itself in a saved block so every template carries it, and route it somewhere that creates one of those records.

Why none of this is reportable

mailwizard_campaign.targets is a TEXT('long') column holding JSON. Delivery state lives inside it, per campaign, and SQL cannot join to it, group by it or count across it.

That single fact explains most of the constraints on this page: why no screen can tell you which campaigns hold a given address, why there is no per-recipient count anywhere, why the progress bar has to parse the list in the browser to draw itself, and why the analytics header on the Campaigns screen reports on campaign rows rather than on mail. See The admin screens.

It is also why a malformed list was once catastrophic. A value that parsed but was not an array reached the send loop and threw outside the per-campaign guard, which aborted the entire run — one bad campaign stopped every other campaign on the platform from sending, and stayed ACTIVE to do it again an hour later. Both the write routes and the job now validate the shape: a list that cannot be read gets its campaign paused and logged, and the rest of the run continues.

If you need reporting across campaigns, it has to come from reading and parsing that column yourself, or from your mail provider's own dashboard.

Next: API reference for the exact request and response shapes, or How sending works for the job that reads all of this.