← Blog

Email automation

Email-automation: 4.120 supermarktklachten per week

Een Haarlemse supermarkt met 28 mensen krijgt 4.120 bezorgklachten per week op een Magento 1.9-shop uit 2013. Zo brachten we de inbox met 70% omlaag.

Jacob Molkenboer· Oprichter · A Brand New Company· 16 jun 2026· 9 min
Gesloten manila envelop met groen lint op donker vloeiblad, koperen bel en crème indexkaart op ivoren bureau.

Maandag, 06:14, een dispatchbalie boven het magazijn in Haarlem. De nachtdienst heeft 837 klantmails laten staan in de mailbox klantenservice@. Twee servicemedewerkers komen om zeven uur binnen, en voor de lunch landen er nog eens 1.200 mails bovenop. De bezorgwagen van gisteren miste twaalf adressen in Heemstede omdat een zijstraat dicht was voor bruginspectie. Het WhatsApp-bericht van de dispatcher aan de teamlead bevat één woord: help.

Dit is een online supermarkt met 28 mensen. Twaalf orderpickers, zes chauffeurs, vier kantoormedewerkers, twee servicemedewerkers, de oprichters, een voorraadbeheerder, een boekhouder. Ze draaien zo'n €18M omzet op een webshop die sinds 2013 online staat. De webshop is Magento 1.9. Het magazijn draait op een eigen Symfony 2-systeem dat de zwager van iemand ooit op een lange winter heeft geschreven, en niemand durft het aan te raken.

Ze vroegen ons om de inbox kleiner te maken. Niet om de stack te vervangen. Niet om naar Magento 2 te migreren. Niet om het WMS overboord te gooien. Alleen om te voorkomen dat de twee servicemedewerkers elke maandag verzuipen.

Dit is het playbook dat we hebben gedraaid.

Hoe 4.120 klachten per week er echt uitzien

Voordat een agent ook maar in de buurt van productie komt, moet je weten wat de vorm van de inbox is. We hebben twaalf weken aan mails uit het IMAP-archief getrokken en handmatig geclusterd. De uitkomst was lastiger te negeren dan welk taartdiagram dan ook.

  • Gemist bezorgmoment of te laat: 38%. Chauffeur was te laat, klant niet thuis, pakket terug naar het depot.
  • Verkeerd of ontbrekend artikel: 27%. Picker scande de verkeerde SKU, of de vervangingsregel sloeg aan en de klant was het er niet mee eens.
  • Kwaliteitsklacht, niet-allergisch: 14%. Beurse avocado, zure melk een dag voor THT, gebroken ei.
  • Achter een refund aan: 9%. "Ik heb vorige week dinsdag al gemaild over de ontbrekende yoghurt."
  • Allergie-klacht: 4%. Amandelen in een "notenvrije" schaal, soja in een als sojavrij gelabelde saus, gluten in een "glutenvrij" brood.
  • De rest: 8%. Adreswijzigingen, password resets, AVG-exports, en af en toe een huwelijksaanzoek aan de bakkerijploeg.

Die 4% allergiebucket is klein in volume en enorm in risico. Eén slecht afgehandelde mail daar is geen refund-probleem, het is een aansprakelijkheidsprobleem en een toezichthoudersprobleem. Daar komen we op terug.

De vorm van de stack die we niet konden veranderen

Magento 1.9 bereikte zijn end of life in juni 2020. De ondernemer wist het. Er lagen twee mislukte migratiepogingen achter ze, beide vastgelopen op de eigen checkout die statiegeld per krat berekent. Het Symfony WMS praat met Magento via een nachtelijke cron en een REST-endpoint dat op de helft van de error-paden de verkeerde HTTP-code teruggeeft. Orderdata leeft in sales_flat_order. Vervangingsorders leven in een aparte tabel die het WMS beheert. Refund credit memos leven in Magento. Drie databases, twee waarheden.

De opdracht was simpel. We schrijven niet naar Magento. We schrijven niet naar het WMS. We lezen alleen uit allebei. De agent leeft in een apart proces, leest de inbox via IMAP, stuurt uitgaande mail via een relay, en parkeert elke menselijke actie die hij niet kan doen in een queue.

Waarschuwing

Als een verouderd systeem HTTP 200 teruggeeft met een error-body, gelooft je agent vrolijk dat de refund is doorgevoerd. Lees de body, niet de statusregel. Dat hebben we op dag drie geleerd.

Classificatie vóór generatie

Elke mail komt in dezelfde trechter terecht. Voordat een language model in de buurt van de inhoud komt, beslist een classifier wat voor mail het is en welke data opgehaald moet worden. De classifier is een korte prompt met een strikt JSON-schema, gedraaid op een goedkoop model. Het sterkere model ziet alleen mails die door de eerste filter heen komen.

// app/Console/Command/InboxIngestCommand.php
public function handle(MailEnvelope $envelope): void
{
    $order = $this->orderResolver->fromMail($envelope);

    $classification = $this->classifier->classify([
        'subject' => $envelope->subject(),
        'body'    => $envelope->plainBody(),
        'order'   => $order?->summary(),
    ]);

    match ($classification->intent) {
        Intent::MissedWindow      => $this->deliveryFlow->handle($envelope, $order, $classification),
        Intent::WrongOrMissing    => $this->wrongItemFlow->handle($envelope, $order, $classification),
        Intent::QualityNonAllergy => $this->qualityFlow->handle($envelope, $order, $classification),
        Intent::RefundChase       => $this->refundChaseFlow->handle($envelope, $order, $classification),
        Intent::Allergy           => $this->allergyFlow->handle($envelope, $order, $classification),
        Intent::Other             => $this->humanQueue->park($envelope, 'unclassified'),
    };
}

De order resolver is het saaie deel waar niemand een thread over schrijft. Hij probeert eerst het ordernummer uit het onderwerp, daarna het mailadres van de klant, daarna de laatste zes cijfers in de body, en als laatste een fuzzy match op postcode plus bezorgdatum. Ongeveer 91% van de mails wordt op de eerste poging aan één order gekoppeld. De rest gaat naar de menselijke queue met een notitie welke kandidaten de resolver heeft gevonden.

De refund-drempel als poort

De oprichters hadden één regel waar niet over te onderhandelen viel. De agent mag op eigen houtje een pak boter terugbetalen. Hij mag geen wekelijkse boodschappen terugbetalen. De grens die zij wilden, lag op €40.

Dat ene getal veranderde de hele structuur van het systeem. Elke refund-flow heeft twee vertakkingen. Onder de drempel: automatisch terugbetalen en de excuses sturen. Boven de drempel: de case opbouwen en parkeren voor de klantenservice-lead. De lead ziet een samenvatting op één scherm met de klanthistorie, de betwiste order, het berekende refund-bedrag, het voorgestelde antwoord, en één knop.

// app/Service/RefundDecision.php
public function decide(Order $order, Money $proposed): RefundDecision
{
    if ($proposed->greaterThan(Money::EUR(4000))) {
        return RefundDecision::queueForLead(
            reason: 'over_threshold',
            evidence: $this->evidence->build($order),
        );
    }

    if ($this->history->refundsInLast30Days($order->customerId())->greaterThan(Money::EUR(8000))) {
        return RefundDecision::queueForLead(
            reason: 'repeat_claimant',
            evidence: $this->evidence->build($order),
        );
    }

    return RefundDecision::auto($proposed);
}

Twee drempels, niet één. De tweede telt zwaarder dan de ondernemer had verwacht. Ongeveer 0,6% van de klanten diende op vrijwel elke order een klacht in. Toen we dat zichtbaar maakten voor de lead, kregen zes accounts een vriendelijk telefoontje en daalde het refund-volume op die accounts de maand erna met 84%. De agent ving geen fraude. De agent maakte het patroon zichtbaar, een mens las het, een mens belde.

Het vier-ogen-principe voor allergieclaims

Dit is het deel van het playbook waar we het meest trots op zijn, en het deel met de minste code. Elke mail die de classifier als Intent::Allergy markeert, wordt uit de auto-send-pijplijn gehouden. Punt. De mail gaat een aparte queue in waar twee benoemde mensen moeten aftekenen voordat de SMTP-relay het uitgaande antwoord accepteert.

De eerste persoon is de servicemedewerker die het concept heeft geschreven. De tweede is de kwaliteitsborging-lead, die de leveranciersregistratie beheert. De relay laat het bericht alleen door als beide aftekeningen aanwezig zijn én het message id matcht met het case id. Probeert een servicemedewerker de queue te omzeilen door vanuit een privémailbox te antwoorden, dan wijst de uitgaande relay het af. Elke service-mailbox loopt namelijk via diezelfde relay.

// app/Mail/RelayGuard.php
public function shouldSend(OutboundMail $mail): RelayDecision
{
    $case = $this->cases->findByMessageId($mail->inReplyTo());

    if ($case?->intent === Intent::Allergy) {
        if (!$case->hasSignoff(Role::ServiceAgent)) {
            return RelayDecision::reject('allergy_needs_agent_signoff');
        }
        if (!$case->hasSignoff(Role::QualityLead)) {
            return RelayDecision::reject('allergy_needs_quality_signoff');
        }
    }

    return RelayDecision::accept();
}

De poort staat vóór de SMTP-relay, niet vóór de mailclient. Dat detail telt. Een poort binnen de mailclient kun je omzeilen door van client te wisselen. Een poort bij de relay kun je niet omzeilen zonder het pand uit te lopen.

Belangrijkste les

De goedkoopste plek om beleid af te dwingen, is de laag waar geen mens omheen kan. Voor email is dat de SMTP-relay, niet de inbox.

Toon en het excuusbrief-probleem

Een automatisch geschreven excuusbrief in het Nederlands kan op twee specifieke manieren misgaan. Eén: hij klinkt als een persbericht. Twee: er staan woorden in die geen Haarlemmer ooit in een echt gesprek zou gebruiken. We hebben twee dagen zitten lezen in echte antwoorden die de eerste servicemedewerker het afgelopen jaar had verstuurd, en daaruit een toonprofiel opgebouwd.

De regels werden saai en effectief. Spreek de klant aan met de voornaam als die in de order staat. Noem het product dat fout ging bij naam, niet "het artikel". Noem de voornaam van de chauffeur als het een bezorgklacht is en deze chauffeur normaal die postcode rijdt. Sluit af met een zin die het volgende concrete ding noemt dat er gaat gebeuren, niet "wij hopen u hiermee voldoende te hebben geïnformeerd".

De toon-tests draaien in CI. Elke wijziging aan de prompt loopt langs een gouden set van vijftig echte klantmails, en een aparte evaluator checkt op de verboden frases. Vindt de evaluator er meer dan twee in die set, dan faalt de deploy.

De audit trail waar niemand om vraagt totdat het moet

Elke binnenkomende mail, elke classificatie-uitkomst, elke modelcall, elke aftekening, elke uitgaande mail, elke relay-beslissing gaat een append-only log in met een hash-keten. De hash-keten is overkill voor een supermarkt. De append-only log is dat niet. Toen de NVWA in februari belde over een allergie-incident, trok de ondernemer in elf minuten het volledige tijdpad van die case erbij. Het dossier liet zien wie de mail had gezien, wat ze hadden besloten, welke leveranciersbatch erbij hoorde, en welk antwoord de deur uit was gegaan. Dat telefoongesprek had uren kunnen duren.

Aan de bounce-kant parseert de relay delivery status notifications volgens RFC 3464 en zet harde bounces terug in het klantdossier. Zachte bounces worden opnieuw geprobeerd op een backoff-schema. Wat drie keer bounct, krijgt een vlag op het klantprofiel, zodat de volgende medewerker die het account oppakt weet dat het adres niet klopt.

Wat de cijfers in twaalf weken deden

De agent ging in golven live. Eerst alleen de intent voor gemiste bezorgmomenten, daarna verkeerd-of-ontbrekend, daarna kwaliteit, daarna refund-chase. Allergie ging nooit op auto-send en gaat dat ook nooit doen.

Na twaalf weken handelt de agent van de 4.120 mails per week er 2.840 volledig zelf af, zonder mens in de keten. Ongeveer 980 worden door de agent opgesteld en door een servicemedewerker met hooguit één aanpassing goedgekeurd. De resterende 300 blijven volledig handwerk. De twee servicemedewerkers gingen van verzuipen naar tijd hebben om de repeat-claimant accounts te bellen en één keer per week mee te lopen met de pickers in het magazijn. De lead heeft 's ochtends dertig tot veertig refunds om te beoordelen, in plaats van zevenhonderd ongelezen mails.

De Magento 1.9-shop staat er nog. Het Symfony WMS staat er nog. Niemand heeft een regel code in een van beide aangeraakt.

Wat je hieruit meeneemt

Heb je een verouderde stack en een verzuipende inbox? Begin dan niet met een migratieplan. Begin met een classifier en een queue. Lees uit je oude systemen. Schrijf er niet naartoe. Zet je poorten vóór de relay, niet vóór de mensen. Stel één geldbedrag-drempel in en één drempel voor repeat claimants, en laat je lead beide zien. En wat je ook doet, stuur de hoog-risico intents langs twee benoemde mensen voordat één byte een uitgaande socket bereikt.

Toen we dit bouwden voor de Haarlemse supermarkt, zat het lastige deel niet in het model en niet in de prompt. Het lastige deel zat in de order resolver en de relay guard. Dezelfde patronen liggen onder elk process automation-project dat we opleveren, of de inbox nu van een supermarkt of een SaaS-supportqueue is.

Audit van vijf minuten op je eigen inbox: trek de mails van afgelopen week erbij, cluster ze handmatig in zes bakjes, en omcirkel het bakje met het meeste risico. Dat bakje krijgt de vier-ogen-poort. De rest kan wachten.

Kern

Zet je policy-poort bij de SMTP-relay, niet bij de mailclient. Het is de enige laag in de stack waar je team niet omheen kan.

FAQ

Waarom niet eerst weg van Magento 1.9?

Omdat een migratie een meerjarenproject is en de inbox een dagelijkse brand. Los de dagelijkse brand op met een zijproces dat alleen uit de oude stack leest. De migratie wordt later makkelijker, want je hebt nu schone classificatiedata.

Kan de agent zelf refunds doorvoeren in Magento?

Technisch wel, in de praktijk niet. De agent stelt alleen refunds voor. Een mens klikt op de knop die Magento aanroept, ook bij bedragen onder de €40. De audit trail is meer waard dan de bespaarde klik.

Wat gebeurt er met allergiemails als beide aftekenaars er niet zijn?

De mail blijft in de queue staan. We hangen er een 24-uurs SLA-timer aan, en een van de oprichters staat als derde naam op de on-call lijst. De agent verstuurt onder geen enkele omstandigheid een allergie-antwoord op eigen houtje.

Hoe voorkom je dat de toon gegenereerd klinkt?

Bouw een toonprofiel op uit echte antwoorden die je beste medewerker heeft geschreven, en laat elke promptwijziging in CI langs een gouden set van vijftig klantmails lopen. Vindt de evaluator voor verboden frases er meer dan twee, dan faalt de deploy.

Werkt deze aanpak ook voor niet-Nederlandse inboxen?

Ja. De vorm van het playbook is taalonafhankelijk. Het toonprofiel en de classifier-prompts veranderen per taal, maar de drempel-poort, de vier-ogen-poort en de relay guard blijven hetzelfde.

email automationai agentsmagentolegacy sitesworkflowoperations

Iets bouwen?

Start een project