EPS Send Integrity — What Ships, What's Left

Plutino EPS · eps.plutinogroup.com · Send integrity

Six people got it. The record says nobody did.

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

Correction — supersedes the previous version of this page

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

The duplicate send is fixed

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:

Server-side idempotency
Claim-once submission tokens with a 2-hour TTL. A repeat POST is refused with "Repeated submission blocked. No additional email was sent."BuilderSubmissionGuard.php · public/index.php:2770
Instant front-end lockout
A submitting latch, all submit buttons disabled, pointer events off, label swapped to a spinner and "Sending…".public/index.php:4479–4501
Audible feedback
A synthesised two-tone chime via the Web Audio API, no media file to load.public/index.php:4449 · playSendChime()
The send is off the request
"Send Now" calls queue() and returns. ScheduledJobsRunner does the SMTP work behind a lock file. The browser never waits on the mail server.JobsheetDeliveryService::queue() · public/index.php:2992
Lost-update protection
Optimistic concurrency on storage_revision, with the read-modify-write inside flock(LOCK_EX). Concurrent writers now conflict instead of silently clobbering.Store::saveJobsheet() · Store::mutate()
Regression coverage
The guard, its wiring, the button lockout and the chime are all asserted.tests/builder_submission_guard_regression.php

None of this needs redoing. The rest of this page is about what these changes do not cover.

02 — The remaining defect

A partial send erases itself

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.

  1. The loop has no guard Each recipient is sent individually, and the per-recipient call sits bare in the loop. A throw on recipient 7 of 14 propagates straight out. EmailSender::sendJobsheetDeliveries() — lines 123–167, no try/catch inside the foreach
  2. Six real emails have already left Recipients 1–6 received the package. Recipients 8–14 are never attempted. The $results array holding those six successes is discarded as the stack unwinds.
  3. The success path is skipped Control never reaches the lines that set status = 'sent', stamp sent_at, mark each delivery sent and call finalizeJobsheetSend(). JobsheetDeliveryService::send() — throws at line 64, success block is lines 79–91
  4. The catch treats it as a total failure The runner catches Throwable and calls markFailed(). There is no branch for "some of them worked". ScheduledJobsRunner.php:63–64 · public/index.php:2994
  5. The evidence is deleted 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
Net effect

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

"Queued" is the last thing she is ever told

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

One new state, and never destroy a send

The application already models draft, queued, scheduled, sent and send_failed. The gap is the state between the last two.

Draft Queued Sending Sent Partly sent Not sent

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.

Three rules

// 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

What the operator sees

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

Plutino EPSDashboardTalentHistoryAddress book

Sending — 6 of 14 accepted

Queued

Spring/Summer 2026 — Talent Recommendations. Started 4:11 PM. Close this page if you like; sending continues.

6 accepted · 8 remaining

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

Plutino EPSDashboardTalentHistoryAddress book

12 of 14 sent — 2 addresses failed

Partly sent

The 12 who received it keep their package and their links. A retry goes to the 2 failed addresses only.

RecipientResultReasonTime
Dana Whitlock — Ilex AgencyFailedMailbox does not exist (550)4:11:58 PM
Marc Feld — OssatureFailedMessage exceeds recipient size limit4:12:03 PM
Sarah Ellery — Marchetti StudioSent4:11:42 PM
Priya Raman — Halden & RoeSent4: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

Plutino EPSDashboardTalentHistoryAddress book

Sent · 26 Oct 2026, 4:12 PM

Sent

All 14 recipients accepted. 6 have opened the package. Sent by Roseanna Plutino.

Package
Eliza Reed · Liam Chen · Sophie Vo · Mark Gya · Boria Raig · Ren Okada

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

Smallest safe first

  1. Guard the send loop per recipientStops one refused address from failing thirteen good ones. Contained to EmailSender.
    Hours
  2. Stop discarding delivered snapshotsCondition the wipe in markFailed() on zero successful deliveries. Ends the dead-portal-link and lost-attachment outcome.
    Hours
  3. Add sent_partialStatus, dashboard rendering, and a retry scoped to failed deliveries only.
    1–2 days
  4. Resolve the queue promiseDashboard reports sending progress and the final outcome, so "queued" always ends in an answer.
    1–2 days
  5. Move the chime to the resultFires on sent or failed rather than on submit, as an opt-in preference.
    Half a day

Tests to add to tests/run.php

The first two items are small, independently shippable, and stop live damage. Everything after is interface work that can follow at its own pace.