Plutino EPS · eps.plutinogroup.com · Send integrity
The duplicate-send work already shipped and it holds. What remains is a failure path that deletes the evidence of a partial send — and then invites the operator to send the whole package again.
Note on this revision
The first version of this document analysed _old/public/wp-content/plugins/eps-mailer/ and proposed a WordPress companion plugin. That was the wrong codebase. The live dashboard is the standalone plutino-eps application: httpdocs/index.php loads app/bootstrap.php, not WordPress.
Its account of the cause was also wrong. Clicks two through seven did not slip past a WordPress status gate. Everything below is read from the running application.
01 — Already done
Roseanna's report — "I press send and nothing happens that I hear or see and by habit I press it again….. First time I did it, I sent it 7 times" — is answered by work already in the tree. Stated plainly so it isn't re-litigated or rebuilt:
submitting latch, all submit buttons disabled, pointer events off, label swapped to a spinner and "Sending…".public/index.php:4479–4501queue() and returns. ScheduledJobsRunner does the SMTP work behind a lock file. The browser never waits on the mail server.JobsheetDeliveryService::queue() · public/index.php:2992storage_revision, with the read-modify-write inside flock(LOCK_EX). Concurrent writers now conflict instead of silently clobbering.Store::saveJobsheet() · Store::mutate()None of this needs redoing. The rest of this page is about what these changes do not cover.
02 — The remaining defect
There is no per-recipient error handling in the send loop. One refused address mid-list does not fail that address — it fails the jobsheet, and the cleanup deletes the proof that anyone was reached.
$results array holding those six successes is discarded as the stack unwinds.
status = 'sent', stamp sent_at, mark each delivery sent and call finalizeJobsheetSend().
JobsheetDeliveryService::send() — throws at line 64, success block is lines 79–91
Throwable and calls markFailed(). There is no branch for "some of them worked".
ScheduledJobsRunner.php:63–64 · public/index.php:2994
markJobsheetSendFailed() sets deliveries = [], package_snapshot = null and tracking_token = ''. Then discard() calls removeDirectory() on the frozen snapshot — the package images come off disk.
Store.php:545–548 · PackageSnapshotService::discard() line 463
Six clients hold the package. The dashboard records zero deliveries, shows the jobsheet as failed, and has deleted the snapshot their portal links point at. Those links are now dead. And the only recovery the interface offers is Send again — which delivers to all fourteen, so the first six receive it twice.
This needs no concurrency to happen. It is deterministic, single-threaded, and it reproduces the reported production symptom — a missing attachment and missing successful records — on its own. Worth confirming whether the incident under investigation was this path, the concurrent-write path, or both.
03 — The second gap
The interactive path is honest where it matters: it queues, and it says "Email queued successfully" rather than claiming a send. That is the right word.
But nothing resolves it. The runner picks the job up later, out of sight. If it succeeds, no one says so. If it fails, no one says so. The operator's last information is a flash message about a queue, on a page she has already navigated away from.
That is the same doubt that produced seven clicks, displaced by a few minutes. A confirmation that never resolves is a confirmation with a question mark on the end.
Related: the chime fires on submit (index.php:4498). It confirms the button, not the delivery — the one thing that was never in doubt.
04 — Proposal
The application already models draft, queued, scheduled, sent and send_failed. The gap is the state between the last two.
States are told apart by form rather than colour — outline, dashed, filled — in line with the site guide's rule against decorative accents. Failure is the one exception, because it is the only state where a glance must not be ambiguous.
sent or failed with the reason on that delivery, and carry on down the list. A refused address is that address's problem.markFailed() may only wipe the snapshot when zero deliveries succeeded. Once one email is out, the frozen package is a live document — someone holds a link to it.failed. Re-blasting the whole list is not something the operator must remember to avoid; it is something the system cannot do.// EmailSender::sendJobsheetDeliveries() — inside the foreach
try {
$result = $this->sendJobsheet($deliveryJobsheet, $fromUser, $portalUrl, $trackingUrl, $unsubscribeUrl);
$results[] = ['delivery_id' => ..., 'status' => 'sent', 'transport' => ...];
} catch (Throwable $error) {
$results[] = ['delivery_id' => ..., 'status' => 'failed', 'error' => $error->getMessage()];
}
// JobsheetDeliveryService::send() — replace the unconditional loop that marks every delivery sent
$failed = array_filter($results, fn($r) => $r['status'] === 'failed');
$jobsheet['status'] = $failed ? (count($failed) === count($results) ? 'send_failed' : 'sent_partial') : 'sent';
Each delivery takes its status from its own result rather than from the fact that the loop finished. Today every delivery is stamped sent unconditionally after the call returns.
05 — Screens
Drawn to the site guide: cream ground, black type, Barlow Condensed for labels and controls. Three states — the two that exist but go unreported, and the one being added.
A — Queued, and it resolvesDashboard · jobsheet row
Spring/Summer 2026 — Talent Recommendations. Started 4:11 PM. Close this page if you like; sending continues.
One bar per recipient, filling as the runner works. The dashboard already polls the scheduler; this reads the delivery ledger it is already writing. "Close this page if you like" is the sentence that stops the second click — it removes the reason to sit and watch.
B — Partly sentThe state being added
The 12 who received it keep their package and their links. A retry goes to the 2 failed addresses only.
| Recipient | Result | Reason | Time |
|---|---|---|---|
| Dana Whitlock — Ilex Agency | Failed | Mailbox does not exist (550) | 4:11:58 PM |
| Marc Feld — Ossature | Failed | Message exceeds recipient size limit | 4:12:03 PM |
| Sarah Ellery — Marchetti Studio | Sent | — | 4:11:42 PM |
| Priya Raman — Halden & Roe | Sent | — | 4:11:44 PM |
This is the screen that prevents the duplicate the submission guard cannot see — a second send from a fresh page load with a legitimately new token. Without it, one bad address out of fourteen leaves only one route to recovery, and thirteen people get the package twice.
C — SentThe record, not a notification
All 14 recipients accepted. 6 have opened the package. Sent by Roseanna Plutino.
There is no Send control on a sent jobsheet, so the impulse to press it again never arises. Open counts come from the RecipientDelivery tracking tokens the app already issues. This is the standing answer to "no visibility into what was sent, to whom, and when."
06 — Order of work
EmailSender.markFailed() on zero successful deliveries. Ends the dead-portal-link and lost-attachment outcome.sent_partialStatus, dashboard rendering, and a retry scoped to failed deliveries only.tests/run.phpfailed, status sent_partial, snapshot still on disk, tracking token intact.send_failed and that the snapshot is discarded — the existing behaviour, now correctly scoped.The first two items are small, independently shippable, and stop live damage. Everything after is interface work that can follow at its own pace.