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.
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
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
-
Find every campaign holding the address. There is no search for this —
targetsis a single JSON column, so no screen and no filter can look inside it. The view dialog on/admin/mailwizard/campaignlists 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, readmailwizard_campaign.targetsdirectly and search it. Include soft-deleted campaigns if you keep them — the table is paranoid, and a deleted row still holds the JSON. -
Pause anything
ACTIVE. The Pause button on the campaign page. Do not use Stop — see the next section. -
Rewrite each list without that entry, through
PUT /api/admin/mailwizard/campaign/{id}/target, one campaign at a time. -
Add the person to your own suppression record, outside the product. See below.
-
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
/targetendpoint. - 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.