136 rejected sends sat in the accounting logs over one week, and nobody had looked at what was in that pile.

We run mail through our MTA behind the platform, and rejections get logged the same way bounces do, envId keyed, one CSV per day. An earlier fix had already burned us on single-day files. The report that joined order rows to MTA accounting by envId only read one day’s file, so anything still retrying at midnight rolled into the next day’s CSV and rendered as “inQueue/noData.” Staff read that as “never sent” when the message had actually gone out and bounced. That taught us not to trust a rejection count from a single file or a single query. So when 136 showed up, we asked a session to pull the raw accounting rows and join them back to real orders before anyone touched a keyboard about it.

The join

The session read the week’s accounting CSVs, matched envId back to order and customer records, and split the 136 into two piles. 99 were internal batch mail, staff notifications and system chatter that nobody outside the company ever saw. The other 37 were customer-facing, spread across roughly 25 distinct addresses. Within those 37, it broke the rows out by message type and found expiry notices were the biggest category. These were renewal and lapse warnings customers never got, so a chunk of those 25 people had a site heading toward expiration with no warning delivered. The split came straight out of the join. Some customers had multiple failed sends stacked on the same address, which is why 37 rejected messages maps to about 25 people.

That made this different from a routine cleanup ticket. A batch-mail rejection is noise. An expiry notice that failed without anyone noticing means a customer finds out their site is down when it is already down.

The blast apology we almost queued

Our first instinct was to have the session draft a single apology, one template, mail merged across all 25 addresses, out the same day. We had it draft that version. It came back technically fine: correct tone, correct facts, a generic “we noticed a delivery issue affecting your account” line. Then we read the underlying rows again next to the draft and saw the problem. Eight of those addresses belonged to accounts with prior support history on the same thread, in some cases with names attached in the message body of earlier exchanges. A form email to those eight would have read as a company that doesn’t know its own customers, on the exact subject where that matters most. We killed the blast draft. That was a wrong turn, not a hypothetical one, because the session was ready to queue it before we caught the problem in the diff.

So the session redid the split into two customer-facing apology templates. One was generic, for the 17 with no prior thread. It also named the 8 accounts that needed a human-written reply. It drafted both templates and sent neither. It queued nothing in the mail system and touched no mail-server configuration: no retry settings, no queue priority, nothing. It left the ticket open with the drafts attached and a note listing exactly what it had and hadn’t done.

The second pass

Before we acted on any of it, we ran a second, independent session against the same raw CSV. It had no shared context and no pointer to the first session’s output, only the source files. Its job was to recompute the counts and the address list from scratch. It got 136 total, 99 internal, 37 customer-facing, roughly 25 addresses, expiry notices as the largest customer-facing category, and the same 8 accounts flagged for a human reply. It matched row for row.

A rejection count that is off by even a few changes who gets a form letter and who gets a personal one, and a miss in the wrong direction is its own kind of damage. Running the same join twice, blind, cost one extra session and confirmed the numbers were real before either drafted email went near a send queue.

The analysis never needed us in the loop. The session could read the accounting files, do the join, count the categories, and draft the language faster and more carefully than we would have by hand at 6 AM. The drafts stayed in the ticket and never reached the outbox. The automation got the counting right twice and never got near the send button.

Join the rejection logs to real customer records first, recompute the count blind from the raw files, and only then read the draft against the rows it will land on. The counts told us who was affected. The rows told us who needed a person to write to them, and we only saw that on the second read.