# Mails sturen

Mailables, Markdown mails en de queue in Laravel, Postmark als provider, en SPF, DKIM en DMARC zodat de mail in de inbox landt.

Multimedia & Creatieve Technologie · 3.1 The Agency · Zelfstudie · versie 1.0.0 · 2026-09-30

Tags: laravel, php, mail, webhooks

Slides: <https://slides.mctantwerp.be/nl/the-agency/15-mail>

<!-- slide 2 -->

## Agenda

1. Mailables
2. Markdown mails
3. Versturen
4. Lokaal testen
5. Postmark
6. SPF, DKIM en DMARC
7. Bounces en klachten

> **Toelichting:** De eerste vier delen gaan over code in je eigen project. Postmark, DNS en bounces spelen zich af buiten Laravel, en daar loopt het in de praktijk het vaakst mis. Heb je al eens een mailable gemaakt, dan kan je snel door het eerste deel, maar sla de slides over de queue niet over.

<!-- slide 3 -->

## Na deze les kan je

- Een mailable schrijven met een onderwerp, een Markdown-view en een pdf als bijlage
- Een mail bekijken in je browser en opvangen in Mailpit, zonder dat er iets naar een echte klant vertrekt
- Mails via de queue sturen, en kiezen tussen een mailable en een notification
- Postmark instellen in Laravel, met een aparte stream voor nieuwsbrieven
- SPF, DKIM en DMARC lezen in een DNS-zone en in de headers van een mail, en uitleggen waarom een mail in spam belandt
- Bounces en spamklachten verwerken met een webhook

> **Toelichting:** Gebruik deze lijst op het einde als checklist. Lukt een punt niet zonder terug te bladeren, dan weet je welk deel je nog eens doorneemt. De opdracht op de voorlaatste slide raakt ze allemaal.

<!-- slide 4 -->

## 1. Mailables

> **Toelichting:** Een mailable is de basis van alles in deze les: ook een notification en een nieuwsbrief eindigen als een mail die Laravel opbouwt en afgeeft aan een transport. Wie de mailable snapt, snapt waar een probleem zit: in je code, in de queue of bij de provider.

<!-- slide 5 -->

### Mails in een klantproject

- **Wachtwoord vergeten**: Zit al in de starter kit, als notification.
- **Bestelling bevestigd**: Met het overzicht en de factuur als pdf.
- **Uitnodiging**: Een collega toevoegen aan een team, met een link die vervalt.
- **Nieuw formulier binnen**: Naar de klant zelf, met Reply-To op het adres van de bezoeker.

Na een lancering is "de mail komt niet aan" een van de klachten die je het vaakst hoort. Meestal ligt dat niet aan je code maar aan DNS. Daarom gaat de tweede helft van deze les over wat er buiten Laravel gebeurt.

> **Toelichting:** Denk bij elk project na de eerste klantmeeting na welke mails erin zitten, en zet ze op je planning. Mails worden vaak pas op het einde toegevoegd, en dan blijkt dat de klant nog teksten, een logo en een afzenderadres moet aanleveren. Dat laatste vraagt ook DNS-werk, zie het deel over SPF en DKIM.

<!-- slide 6 -->

### Een mailable

- Elke soort mail krijgt een eigen class in `app/Mail`. Onderwerp, view en bijlagen van die mail staan dan op één plek.
- Wat de mail nodig heeft, zoals de order, geef je mee in de constructor
- Noem hem naar wat hij doet, met `Mail` erachter. Dan zie je in een lijst classes meteen welke een mail is.
- Meer in de docs: [Generating mailables](https://laravel.com/docs/13.x/mail#generating-mailables)

```console
$ php artisan make:mail OrderConfirmedMail
INFO  Mailable created successfully.
```

> **Toelichting:** make:mail zet de class in app/Mail. Zonder --markdown krijg je een mailable met een gewone Blade-view, de optie komt in het tweede deel aan bod. De naam met Mail erachter is een afspraak uit de richtlijnen van Spatie, geen regel van Laravel, maar het maakt een grote codebase veel leesbaarder.

<!-- slide 7 -->

### OrderConfirmedMail

```php
class OrderConfirmedMail extends Mailable
{
    use Queueable;
    use SerializesModels;

    public function __construct(public Order $order) {}
}
```

De twee traits zet `make:mail` er zelf in. Laat ze staan: je hebt ze nodig zodra de mail via de queue vertrekt.

Een `public` property is vanzelf beschikbaar in de view. `$order` kan je daar meteen gebruiken, zonder het door te geven.

> **Toelichting:** SerializesModels zorgt ervoor dat een model als $order op de queue alleen als id wordt bewaard. De worker haalt het verse model opnieuw uit de database. Stuur je dus een mail over een order die intussen gewijzigd is, dan ziet de mail de nieuwe versie.

<!-- slide 8 -->

### envelope() en content()

```php
public function envelope(): Envelope
{
    return new Envelope(
        subject: 'Je bestelling is bevestigd',
    );
}

public function content(): Content
{
    return new Content(view: 'mail.orders.confirmed');
}
```

`envelope()` gaat over alles rond de mail: onderwerp, afzender, Reply-To. De inhoud komt er niet in.

`content()` kiest de Blade-view, hier `resources/views/mail/orders/confirmed.blade.php`.

Docs: [Writing mailables](https://laravel.com/docs/13.x/mail#writing-mailables)

> **Toelichting:** Zet je geen afzender in envelope(), dan gebruikt Laravel MAIL_FROM_ADDRESS en MAIL_FROM_NAME uit je .env. Voor bijna elk project is dat genoeg. Een eigen from per mailable is zinvol als een deel van de mails van een ander adres komt, zoals facturen van boekhouding@.

<!-- slide 9 -->

### Bijlagen

```php
public function attachments(): array
{
    return [
        Attachment::fromStorage($this->order->invoice_path)
            ->as("factuur-{$this->order->number}.pdf")
            ->withMime('application/pdf'),
    ];
}
```

`fromStorage` leest het bestand van je default disk. Een andere disk: `fromStorageDisk('s3', ...)`.

`as()` is de naam die de klant ziet, niet de naam op je disk.

Het MIME-type zegt de mailapp dat het een pdf is.

> [!WARNING]
> Postmark aanvaardt 10 MB per mail, bijlagen inbegrepen. Groter? Stuur een link naar de download.

> **Toelichting:** Een pdf die je net in het geheugen maakte, hoef je niet eerst op te slaan: Attachment::fromData(fn () => $pdf, 'factuur.pdf') hangt hem rechtstreeks aan de mail.

<!-- slide 10 -->

### Waar zet je het onderwerp van een mailable?

- A. `envelope()`
- B. `content()`
- C. `attachments()`
- D. Bovenaan in de Blade-view

**Antwoord:** A. `envelope()`

envelope() gaat over alles rond de mail: onderwerp, afzender, Reply-To, tags. content() lijkt logisch omdat het onderwerp tekst is, maar content() kiest alleen de view voor de body. Een onderwerp in de view zelf zou in de mail staan, niet in de onderwerpregel.

> **Toelichting:** Een veelgemaakte fout is het onderwerp in de view zetten als h1. Dan staat het wel bovenaan in de mail, maar de onderwerpregel in de inbox blijft leeg of toont de naam van de class. Kijk bij twijfel in Mailpit: daar zie je onderwerp en inhoud apart.

<!-- slide 11 -->

## 2. Markdown mails

> **Toelichting:** Markdown mails zijn de snelste weg naar een mail die er in elke mailapp degelijk uitziet. De starter kits gebruiken dezelfde componenten voor de wachtwoordmails, dus je project krijgt vanzelf één stijl voor alle mails.

<!-- slide 12 -->

### Blade of Markdown

#### Blade-view

- Je schrijft de HTML zelf, met tabellen en inline CSS
- Hoe het eruitziet in Outlook, Gmail en Apple Mail test je ook zelf
- Een tekstversie is een aparte view

#### Markdown

- Componenten voor een knop, een panel en een tabel
- Responsive, en al getest in de grote mailapps
- De tekstversie maakt Laravel voor je

> **Toelichting:** Een Blade-view voor mail is geen gewone webpagina: geen externe CSS, geen JavaScript, en afbeeldingen worden door veel mailapps pas getoond na een klik. Test een eigen template daarom altijd in meerdere apps. Diensten als Litmus of Email on Acid tonen hoe je mail eruitziet in tientallen mailapps tegelijk.

<!-- slide 13 -->

### Waarom Markdown de standaard is

HTML voor mail is HTML van vijftien jaar geleden. Outlook op Windows rendert met de engine van Word, dus flexbox en grid bestaan er niet, en CSS moet inline op elk element staan. Met een eigen Blade-view los je dat allemaal zelf op.

> [!TIP]
> Begin met Markdown. Een eigen Blade-view kies je pas als de designer van de klant een mailtemplate aanlevert.

Docs: [Markdown mailables](https://laravel.com/docs/13.x/mail#markdown-mailables)

> **Toelichting:** Moet het toch een eigen template worden, kijk dan naar MJML. Je schrijft eenvoudige componenten, en de tool maakt er de tabellen en inline CSS van die Outlook nodig heeft.

<!-- slide 14 -->

### Een Markdown mailable

```console
$ php artisan make:mail OrderConfirmedMail --markdown=mail.orders.confirmed
```

```php
public function content(): Content
{
    return new Content(
        markdown: 'mail.orders.confirmed',
        with: [
            'firstName' => $this->order->customer->first_name,
            'url' => route('orders.show', $this->order),
        ],
    );
}
```

`markdown:` in plaats van `view:`. Zo weet Laravel dat de view door de Markdown-parser moet.

`with` geeft extra variabelen aan de view, klaar om te tonen.

> **Toelichting:** Public properties van de mailable zijn in de view ook al beschikbaar. with gebruik je voor waarden die je eerst wil klaarzetten, zoals een url of een geformatteerde prijs, zodat de view zelf dom blijft. Zet de property dan protected, anders zijn beide beschikbaar en is het onduidelijk welke je gebruikt.

<!-- slide 15 -->

### Van Markdown naar mail

Volg de gemarkeerde regels links: rechts licht op wat ze in de mail worden.

```md
<x-mail::message>
# Bestelling bevestigd
Hoi {{ $firstName }}, je bestelling is binnen.

<x-mail::button :url="$url">
Bekijk je bestelling
</x-mail::button>

Tot snel,<br>
{{ config('app.name') }}
</x-mail::message>
```

Voorbeeld van de mail "Bestelling 1042 bevestigd" in het standaardthema van Laravel: Bestelling bevestigd, Hoi Lotte, je bestelling is binnen., knop "Bekijk je bestelling", Tot snel, Fietsen Maes.

> **Toelichting:** Laravel rendert de componenten eerst naar HTML met inline CSS, en maakt daarnaast een tekstversie zonder opmaak. Mailapps die geen HTML tonen, of mensen die dat uitgezet hebben, krijgen die tekstversie. Kijk er eens naar in Mailpit, op het tabblad Text.

<!-- slide 16 -->

### Componenten

| Component | Wat je krijgt |
| --- | --- |
| `x-mail::message` | De layout: de naam van je app bovenaan, de witte kaart, de footer |
| `x-mail::button` | Een knop in het midden, met `url` en een `color`: primary, success of error |
| `x-mail::panel` | Een blok met een andere achtergrond, voor wat moet opvallen |
| `x-mail::table` | Een Markdown-tabel als HTML-tabel, met uitlijning per kolom |

> [!WARNING]
> Spring niet in. Vier spaties voor een regel maken er in Markdown een code block van, ook binnen een component.

> **Toelichting:** Een tabel in een mail wordt op een smalle gsm vaak onleesbaar. Hou ze kort, met twee of drie kolommen. Voor een lange lijst artikels is een eenvoudige opsomming met regels tekst vaak beter leesbaar dan een brede tabel.

<!-- slide 17 -->

### In de huisstijl van de klant

```console
$ php artisan vendor:publish --tag=laravel-mail
```

- De componenten komen in `resources/views/vendor/mail`, en daar pas je ze aan
- Kleuren en fonts staan in `html/themes/default.css`. Laravel zet die CSS inline in elke mail.
- Een eigen thema is een nieuw css-bestand in `themes`, met de naam in `MAIL_MARKDOWN_THEME`

> [!WARNING]
> Staat `APP_NAME` op `Laravel`, dan toont de header het logo van Laravel. Ook op de orderbevestiging van je klant.

> **Toelichting:** Na vendor:publish zijn de componenten van jou: een nieuwe versie van Laravel past ze niet meer aan. Publiceer ze dus pas als je echt iets wil veranderen, en hou bij wat je aanpaste. Een logo zet je meestal in header.blade.php, als afbeelding met een volledige url, want een relatief pad werkt niet in een mail.

<!-- slide 18 -->

### Preview in de browser

*routes/web.php*

```php
if (app()->isLocal()) {
    Route::get('mail/order-confirmed', function () {
        $order = Order::latest()->firstOrFail();

        return new OrderConfirmedMail($order);
    });
}
```

De `if` is geen detail. Zonder staat de route ook in productie, en toont hij de laatste bestelling aan wie de url raadt.

Een mailable die je uit een route teruggeeft, rendert Laravel als HTML. Er vertrekt niets.

Sneller dan telkens een bestelling plaatsen om je mail te zien. Docs: [Previewing mailables](https://laravel.com/docs/13.x/mail#previewing-mailables-in-the-browser)

> **Toelichting:** Wil je meerdere mails bekijken, maak dan een route per mailable, of een kleine lijst met links. In plaats van app()->isLocal() kan je de routes ook in een apart routebestand zetten dat je alleen lokaal laadt. Wat je ook kiest: controleer op staging dat de url een 404 geeft.

<!-- slide 19 -->

### Je knop verschijnt als grijze code in plaats van als knop. Wat is er mis?

- A. De url is leeg
- B. De regels in de view springen in
- C. Je gebruikt view: in plaats van markdown:
- D. Het thema is niet gepubliceerd

**Antwoord:** B. De regels in de view springen in

Markdown maakt van een regel met vier spaties ervoor een code block, en je editor springt binnen een component graag in. view: in plaats van markdown: geeft een ander probleem: dan gaat de view niet door de Markdown-renderer, en krijg je een foutmelding of ruwe tekst, geen grijs blok.

> **Toelichting:** Een grijs code block is het klassieke teken van inspringen. Je editor formatteert Blade vaak automatisch en springt dan alles binnen een component in. Zet voor Markdown-mails het automatisch formatteren uit, of controleer na elke save in de preview.

<!-- slide 20 -->

## 3. Versturen

> **Toelichting:** Versturen lijkt het eenvoudigste deel, maar hier beslis je of een trage of haperende mailprovider je hele app meesleurt. De keuze tussen send en queue, en tussen een mailable en een notification, maak je beter één keer per project en schrijf je op in de README.

<!-- slide 21 -->

### Naar de klant

```php
Mail::to($order->customer)
    ->send(new OrderConfirmedMail($order));

Mail::to($order->customer)
    ->bcc('bestellingen@fietsenmaes.be')
    ->send(new OrderConfirmedMail($order));
```

`to()` aanvaardt een adres of een model. Bij een model neemt Laravel `email` en `name`.

Een bcc naar de winkel zelf: de klant ziet het niet, de winkel krijgt een kopie van elke bevestiging.

> [!WARNING]
> In een loop maak je elke keer een nieuwe mailable. `to()` voegt toe, dus met één instantie krijgt de tiende klant de adressen van de negen ervoor.

> **Toelichting:** to() aanvaardt ook een collection van users, maar dan krijgt iedereen dezelfde mail met alle adressen zichtbaar in het To-veld. Voor meerdere klanten wil je bijna altijd een aparte mail per persoon. Mail::to($customer) in een foreach, telkens met een nieuwe mailable, zoals de callout zegt.

<!-- slide 22 -->

### send() en queue()

#### `send()`

- De mail vertrekt tijdens de request, dus de klant wacht op de mailprovider
- Ligt de provider plat, dan krijgt de klant een foutpagina

#### `queue()`

- De mail wordt een job, en de response is meteen terug
- Mislukt het versturen, dan probeert de job het opnieuw

Hoe je een worker draait: zie de les over [queues](/nl/the-agency/13-queues).

> **Toelichting:** Ook lokaal kan je queue() gebruiken: met QUEUE_CONNECTION=sync voert Laravel de job meteen uit, zonder worker. Handig tijdens het ontwikkelen, maar zet het in productie nooit op sync, anders heb je dezelfde nadelen als send().

<!-- slide 23 -->

### Altijd via de queue

```php
class OrderConfirmedMail extends Mailable implements ShouldQueue
{
    use Queueable;
    use SerializesModels;

    public function __construct(public Order $order)
    {
        $this->afterCommit();
    }
}
```

Met `ShouldQueue` gaat ook `send()` via de queue. Zo kan niemand in het team het vergeten.

`afterCommit` wacht tot de databasetransactie klaar is. Anders stuurt een snelle worker een bevestiging voor een order die nog niet bestaat, of die nooit zal bestaan.

Docs: [Queueing mail](https://laravel.com/docs/13.x/mail#queueing-mail)

> **Toelichting:** Staat after_commit op true in de configuratie van je queue connection, dan is afterCommit() niet meer nodig. Het expliciet in de mailable zetten kan geen kwaad: dan klopt het ook als iemand die config ooit terugzet.

<!-- slide 24 -->

### De bevestiging vertrekt met send(), en Postmark ligt plat. Wat ziet de klant?

- A. Niets, de mail komt later
- B. Een foutpagina, terwijl de bestelling wel al opgeslagen is
- C. Een bevestiging zonder mail
- D. Laravel stuurt via sendmail

**Antwoord:** B. Een foutpagina, terwijl de bestelling wel al opgeslagen is

send() roept Postmark aan tijdens de request. Faalt die call, dan gooit Laravel een exception, terwijl de order al in de database staat. 'De mail komt later' klopt alleen met de queue: dan probeert de job het opnieuw. Een fallback naar sendmail bestaat alleen als je zelf een failover-mailer instelt.

> **Toelichting:** Een failover-mailer in config/mail.php probeert een tweede mailer als de eerste faalt, bijvoorbeeld postmark en daarna smtp. Dat helpt bij een storing, maar het lost niet op dat de klant wacht tijdens de request. De queue blijft de eerste stap.

<!-- slide 25 -->

### Mailable of notification

|   | Mailable | Notification |
| --- | --- | --- |
| Waarvoor | Een document: bevestiging, factuur, offerte | Een melding: je fiets is klaar, iemand reageerde |
| Kanalen | Alleen mail | Mail, database, Slack, en meer |
| Opmaak | Eigen view, bijlagen | Regels tekst en één knop |
| Versturen | `Mail::to($user)->send(...)` | `$user->notify(...)` |

Wachtwoord reset en e-mailverificatie uit de starter kit zijn notifications. Daarom zien ze eruit als een Markdown mail.

> **Toelichting:** Een notification kan ook een volledige mailable teruggeven uit toMail(). Zo combineer je de kanalen van een notification met de eigen opmaak en bijlagen van een mailable. Dat is handig als dezelfde gebeurtenis ook in een meldingenlijstje in je app moet verschijnen.

<!-- slide 26 -->

### Een notification

```php
public function via(object $notifiable): array
{
    return ['mail', 'database'];
}

public function toMail(object $notifiable): MailMessage
{
    return (new MailMessage)
        ->subject('Je fiets is klaar')
        ->greeting("Hoi {$notifiable->first_name}")
        ->line('Je kan hem vanaf morgen ophalen.')
        ->action('Bekijk je afspraak', $this->url);
}
```

`via()` kiest de kanalen. Voor `database` heb je ook een `toArray()` nodig.

Geen view: je bouwt de mail in regels. `action` wordt de knop.

Docs: [Mail notifications](https://laravel.com/docs/13.x/notifications#mail-notifications)

> **Toelichting:** Notifiable is een trait op je User-model, en die zorgt voor notify(). Wil je een notification sturen naar iemand zonder account, zoals een bezoeker van een formulier, gebruik dan Notification::route('mail', $email)->notify(...). Het database-kanaal heeft een notifications-tabel nodig, die maak je met php artisan make:notifications-table.

<!-- slide 27 -->

## 4. Lokaal testen

> **Toelichting:** Lokaal testen betekent: elke mail zien zoals de klant hem ziet, zonder dat er iets naar een echt adres vertrekt. Een mail die per ongeluk naar een echte klant gaat vanaf je laptop of van staging, is een van de gênantste fouten die je kan maken.

<!-- slide 28 -->

### Waar gaat je mail naartoe

|   | `MAIL_MAILER` | Je ziet de mail |
| --- | --- | --- |
| **Mailpit** | `smtp`, poort 1025 | In je browser, op [localhost:8025](http://localhost:8025) |
| **Herd Pro** | `smtp`, poort 2525 | In het venster Mail van Herd, alleen in de betalende versie |
| **log** | `log` | Als ruwe tekst in `storage/logs/laravel.log` |
| **array** | `array` | Nergens. Staat in `phpunit.xml` voor je tests. |

Mailpit en Herd Pro vangen elke mail op en tonen hem zoals de klant hem ziet. De log driver werkt altijd, maar je leest er ruwe MIME, niet de mail.

> **Toelichting:** De array-driver gebruikt Laravel in je tests, zodat Mail::fake en de asserts werken zonder dat er iets vertrekt. Daarover gaat de les over fakes. Kies voor je eigen project één mail catcher en zet hem in de README, zodat elk teamlid dezelfde setup heeft.

<!-- slide 29 -->

### Mailpit

```console
$ brew install mailpit
$ brew services start mailpit
```

Op Windows download je de binary, of je draait de Docker-image. Beide staan op de [installatiepagina](https://mailpit.axllent.org/docs/install/).

*.env*

```ini
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
```

Stuur een mail en open [localhost:8025](http://localhost:8025). Met Herd Pro zet je de poort op 2525 en kijk je in Herd.

> **Toelichting:** Mailpit heeft ook een HTML check: het toont welke HTML en CSS in je mail niet werkt in mailapps zoals Outlook of Gmail. Handig zodra je een eigen thema maakt.

<!-- slide 30 -->

### Staging met echte data

Stel: staging draait op een kopie van de productiedatabase, en de scheduler stuurt er herinneringen. Dan krijgen echte klanten mails van een testomgeving.

*app/Providers/AppServiceProvider.php*

```php
public function boot(): void
{
    if ($this->app->environment('staging')) {
        Mail::alwaysTo('dev@agency.be');
    }
}
```

`alwaysTo` stuurt elke mail naar dat ene adres, en schrapt cc en bcc.

Mailpit op de staging-server werkt ook. Zolang staging nooit een echt adres bereikt.

> **Toelichting:** Mail::alwaysTo werkt alleen voor mails die via Laravel vertrekken. Stuurt een externe dienst ook mails met data uit staging, zoals een nieuwsbrieftool met een kopie van de klantenlijst, dan helpt dit niet. Anonimiseer bij voorkeur de klantgegevens wanneer je een productiedatabase naar staging kopieert.

<!-- slide 31 -->

## 5. Postmark

> **Toelichting:** Tot hier kwam je mail nooit verder dan je laptop. Vanaf nu vertrekt hij echt, en dan beslist een server bij Gmail of Microsoft of hij in de inbox komt. Postmark is het voorbeeld, maar de ideeën gelden voor elke provider.

<!-- slide 32 -->

### Waarom een provider

- Het IP-adres van je VPS heeft geen reputatie, of een slechte. Mailservers vertrouwen het niet.
- Een provider zet DKIM en een Return-Path voor je domein op
- Bounces en spamklachten komen bij de provider binnen, en via een webhook bij jou
- Elke mail staat in een log. Zegt een klant "ik heb niks gekregen", dan kijk je het na.

> **Toelichting:** Een VPS kan wel zelf mail versturen via sendmail, maar dan moet je zelf SPF, DKIM, een reverse DNS-record en bounceverwerking regelen, en de reputatie van het IP-adres opbouwen. Voor een agency is dat bijna nooit de moeite. Een provider kost een paar euro per maand voor de volumes van een typisch klantproject.

<!-- slide 33 -->

### Waarom Postmark

Postmark is streng over wat er via hun servers mag: elk nieuw account krijgt een review, en nieuwsbrieven gaan langs een aparte weg. Daardoor hebben hun IP-adressen een goede reputatie, en dat is precies wat je koopt.

Laravel kent ook Mailgun, Resend, Amazon SES en Cloudflare. De werkwijze is overal dezelfde, alleen de transport verschilt.

Docs: [Postmark driver](https://laravel.com/docs/13.x/mail#postmark-driver) en de [Postmark developer docs](https://postmarkapp.com/developer)

> **Toelichting:** De prijs van Postmark hangt af van het aantal mails per maand, en er is een gratis testvolume om mee te beginnen. Vergelijk voor een klantproject ook waar de data staat: voor Europese klanten kan het uitmaken of een provider servers in de EU heeft, in het kader van GDPR.

<!-- slide 34 -->

### Message streams

- **Transactional**

  Eén mail voor één persoon, omdat die iets deed. Bestelling, wachtwoord, factuur.

  outbound
- **Broadcast**

  Eén mail naar veel mensen. Nieuwsbrief, aankondiging. Eigen IP's, uitschrijflink verplicht.

  broadcasts

> [!WARNING]
> Een nieuwsbrief via `outbound` mag niet. Nieuwsbrieven krijgen veel meer spamklachten, en die duwen dan ook je wachtwoordmails naar spam.

> **Toelichting:** Een nieuwe broadcast stream bouw je rustig op, eerst met mensen die je mails echt openen. Postmark raadt ook aan om transactional en broadcast op een apart subdomein te zetten, elk met een eigen DKIM.

<!-- slide 35 -->

### Installeren

```console
$ composer require symfony/postmark-mailer symfony/http-client
```

- *config/services.php*

  ```php
  'postmark' => [
      'key' => env('POSTMARK_API_KEY'),
  ],
  ```
- *.env*

  ```ini
  MAIL_MAILER=postmark
  POSTMARK_API_KEY=3f1c9a7e-...
  MAIL_FROM_ADDRESS=bestellingen@fietsenmaes.be
  MAIL_FROM_NAME="Fietsen Maes"
  ```

> [!NOTE]
> Je hebt het **Server API token** nodig, onder API Tokens van je server. Het Account API token werkt hier niet.

> **Toelichting:** Een Postmark-account heeft servers, en elke server heeft zijn eigen token, streams en statistieken. Maak per klant en per omgeving een aparte server, bijvoorbeeld fietsenmaes-production en fietsenmaes-staging. Dan kan een fout op staging nooit de reputatie van productie raken.

<!-- slide 36 -->

### Zolang je account in review staat

Een nieuw Postmark-account wordt eerst nagekeken, meestal binnen een werkdag. Tot dan stuur je alleen naar adressen op domeinen die je zelf geverifieerd hebt. Je kan wel al alles testen.

- **test@blackhole.postmarkapp.com**: Postmark verwerkt de mail en toont hem in Activity, maar niemand ontvangt hem.
- **POSTMARK_API_TEST**: Als token: Postmark checkt of je request klopt en stuurt niets.

Meer: [How does the account approval process work?](https://postmarkapp.com/support/article/1084-how-does-the-account-approval-process-work)

> **Toelichting:** Postmark keurt accounts goed op basis van wat je gaat sturen. Beschrijf bij de aanvraag concreet welke transactionele mails je app stuurt. Een account voor een schoolproject kan je ook gewoon in review laten staan en testen met het blackhole-adres.

<!-- slide 37 -->

### Twee streams, twee mailers

*config/mail.php*

```php
'postmark' => [
    'transport' => 'postmark',
    'message_stream_id' => 'outbound',
],

'postmark_broadcast' => [
    'transport' => 'postmark',
    'message_stream_id' => 'broadcasts',
],
```

Een mailer is een naam met een configuratie. Zonder stream gaat alles naar outbound.

Een tweede mailer met dezelfde transport en een andere stream, voor nieuwsbrieven. De ID vind je in Postmark bij de stream zelf.

```php
Mail::mailer('postmark_broadcast')->to($subscriber)->queue(new SpringSaleMail());
```

> **Toelichting:** Kies je liever een env-variabele voor de stream, dan gebruikt de standaardconfig van Laravel POSTMARK_MESSAGE_STREAM_ID. Met twee mailers maak je twee eigen variabelen, of zet je de IDs rechtstreeks in de config. Stream IDs zijn geen geheim, ze mogen in git.

<!-- slide 38 -->

### Sender signature of domein

#### Sender signature

- Eén adres, zoals `bestellingen@fietsenmaes.be`, bevestigd met een klik op een link in een mail
- Klaar in een minuut, zonder DNS
- Er komt geen DKIM voor het domein van de klant, en dat kost je later de inbox

#### Domein

- Elk adres op `fietsenmaes.be`
- Twee DNS-records: DKIM (TXT) en Return-Path (CNAME)
- Je hebt iemand nodig die de DNS van de klant beheert. Vraag dat in de eerste week van het project, niet in de week van de lancering.

> **Toelichting:** Een sender signature is prima om snel iets te testen, maar niet voor een lancering. Voor een domein geeft Postmark je twee records om aan de beheerder van de DNS te bezorgen. Na het toevoegen duurt het soms enkele uren voor Postmark ze ziet; met de knop Verify kan je opnieuw laten checken.

<!-- slide 39 -->

### Tags en metadata

```php
return new Envelope(
    subject: "Bestelling {$this->order->number} bevestigd",
    replyTo: [new Address('info@fietsenmaes.be')],
    tags: ['order-confirmed'],
    metadata: ['order_id' => $this->order->id],
);
```

Een antwoord op de bevestiging komt zo in de mailbox van de winkel, niet bij een noreply.

Op de tag filter je in Postmark. Eén tag per mail: bij een tweede gooit de transport een exception.

Metadata komt terug in elke webhook, zo koppel je een bounce aan een order.

Docs: [Tags and metadata](https://laravel.com/docs/13.x/mail#tags-and-metadata)

> **Toelichting:** Metadata mag meerdere sleutels hebben, maar hou ze klein: een id of een type, geen volledige objecten. In de Activity van Postmark kan je op metadata zoeken, wat handig is als een klant belt over één bepaalde bestelling.

<!-- slide 40 -->

### Je stuurt de nieuwsbrief via de stream outbound. Wat is het probleem?

- A. Het is trager
- B. Spamklachten op de nieuwsbrief raken ook je transactionele mails
- C. Postmark stuurt hem niet
- D. De tag gaat verloren

**Antwoord:** B. Spamklachten op de nieuwsbrief raken ook je transactionele mails

Een stream deelt zijn IP-adressen en zijn reputatie. Nieuwsbrieven krijgen meer klachten, en dan landen ook de wachtwoordmails in spam. Postmark stuurt hem technisch gezien wel, daarom valt het niet meteen op, maar het is tegen hun regels en ze kunnen je account stilleggen.

> **Toelichting:** Postmark laat niet toe dat je een nieuwsbrief via outbound stuurt, maar controleert dat niet bij elke mail. Het valt op als je klachten of uitschrijvingen oplopen, en dan wordt je account nagekeken. Een aparte broadcast stream is dus geen formaliteit.

<!-- slide 41 -->

## 6. SPF, DKIM en DMARC

> **Toelichting:** Dit deel speelt zich af in de DNS van het domein van de klant, niet in Laravel. Het is de oorzaak van de meeste mails die in spam belanden. Je hoeft de records niet uit het hoofd te kennen, maar je moet ze kunnen lezen en uitleggen aan de klant of zijn IT-partner.

<!-- slide 42 -->

### SMTP checkt niets

Iedereen kan `From: bestellingen@fietsenmaes.be` in een mail typen, en het protocol zelf houdt dat niet tegen. SPF, DKIM en DMARC zijn drie DNS-records waarmee de ontvangende server nagaat of een mail echt van het domein komt dat erop staat.

In de slides hierna staan ze voor één klant, Fietsen Maes. Die heeft zijn mailboxen bij Microsoft 365, en zijn webshop mailt via Postmark.

> **Toelichting:** Voor SPF, DKIM en DMARC bestonden, kon iedereen gewoon een mail sturen met eender welk afzenderadres, en dat gebeurde massaal met phishing. De drie records zijn er stap voor stap bijgekomen. Nu vragen grote mailproviders ze alle drie van wie veel mailt.

<!-- slide 43 -->

### Twee afzenders

#### Return-Path

pm_bounces@pm-bounces.fietsenmaes.be

- Het adres voor de servers, ook envelope sender genoemd
- Hier komen bounces terug
- SPF checkt dit domein

#### From

bestellingen@fietsenmaes.be

- Wat de klant in zijn inbox ziet
- Het domein dat DMARC beschermt

> **Toelichting:** De Return-Path heet ook bounce address of envelope sender, en in de headers van een ontvangen mail zie je hem als Return-Path. Postmark gebruikt standaard een eigen adres op pm.mtasv.net. Met een eigen Return-Path op het domein van de klant komen bounces nog steeds bij Postmark binnen, via de CNAME.

<!-- slide 44 -->

### SPF

*TXT op fietsenmaes.be*

```bash
v=spf1 include:spf.protection.outlook.com -all
```

- Het record somt op welke servers mogen mailen voor een domein. `-all`: alle andere niet.
- Het wordt gecheckt op het domein van de Return-Path, niet op dat van de From
- Eén SPF-record per domein. Twee is een fout en dan faalt SPF voor alles. Een nieuwe dienst zet je erbij in hetzelfde record.
- Postmark hoeft er niet in: `pm-bounces` wijst naar Postmark, en daar staat hun SPF

> **Toelichting:** SPF mag maximaal tien DNS-lookups doen, en elke include telt mee. Een klant die al Microsoft 365, een nieuwsbrieftool en een ticketsysteem gebruikt, zit daar snel aan. Nog een include erbij kan dan SPF voor alles breken. Controleer het record met een tool zoals MXToolbox voor je iets toevoegt.

<!-- slide 45 -->

### DKIM

- *In DNS*

  ```bash
  Host:   20260930104512pm._domainkey
  Type:   TXT
  Waarde: k=rsa; p=MIGfMA0GCSqGSIb3...
  ```
- *In de mail*

  ```bash
  DKIM-Signature: v=1; a=rsa-sha256;
    d=fietsenmaes.be;
    s=20260930104512pm;
    bh=...; b=...
  ```

- Postmark tekent de headers en de body met een private key
- De ontvanger haalt de public key op onder de selector uit `s=`. Zo kan elke dienst een eigen sleutel hebben op hetzelfde domein.
- Klopt de handtekening, dan komt de mail echt van `d=` en is hij onderweg niet aangepast

> **Toelichting:** DKIM overleeft doorsturen, SPF niet. Stuurt iemand je mail door, dan komt hij van een andere server en faalt SPF, maar de handtekening klopt nog. Daarom is DKIM voor DMARC de betrouwbaarste van de twee.

<!-- slide 46 -->

### DMARC

*TXT op \_dmarc.fietsenmaes.be*

```bash
v=DMARC1; p=quarantine; rua=mailto:dmarc@fietsenmaes.be
```

- SPF of DKIM moet slagen, **én** voor het domein van de From. Dat heet alignment.
- `p=` zegt wat de ontvanger doet als dat niet lukt: `none` (niets), `quarantine` (spam) of `reject` (weigeren)
- `rua=` krijgt rapporten over wie er allemaal mailt namens het domein
- Begin met `p=none`, lees een paar weken de rapporten, en schroef dan op

> **Toelichting:** De rapporten op rua zijn XML en slecht leesbaar. DMARC Digests van Postmark maakt er een wekelijkse samenvatting van, zie de slide Nakijken.

<!-- slide 47 -->

### Alignment

| From | Gecheckt domein | Aligned |
| --- | --- | --- |
| `fietsenmaes.be` | SPF op `pm.mtasv.net` | Nee, een ander domein |
| `fietsenmaes.be` | SPF op `pm-bounces.fietsenmaes.be` | Ja, een subdomein telt |
| `fietsenmaes.be` | DKIM met `d=fietsenmaes.be` | Ja |

Een pass alleen is niet genoeg: SPF slaagt ook voor Postmark zelf. Daarom zet je in Postmark een eigen Return-Path en DKIM op het domein van de klant. Standaard is alignment relaxed, dus een subdomein zoals `pm-bounces` telt mee.

> **Toelichting:** Er is ook strict alignment, met aspf=s of adkim=s in het DMARC-record. Dan moet het domein exact overeenkomen en telt een subdomein niet. Dat zie je zelden; de meeste domeinen laten het op relaxed staan.

<!-- slide 48 -->

### De DNS-zone van fietsenmaes.be

| Host | Type | Waarde | Voor |
| --- | --- | --- | --- |
| `@` | MX | `fietsenmaes-be.mail.protection.outlook.com` | Mailboxen bij Microsoft 365 |
| `@` | TXT | `v=spf1 include:spf.protection.outlook.com -all` | SPF |
| `2026…pm._domainkey` | TXT | `k=rsa; p=MIGfMA0G…` | DKIM van Postmark |
| `pm-bounces` | CNAME | `pm.mtasv.net` | Return-Path |
| `_dmarc` | TXT | `v=DMARC1; p=quarantine; rua=…` | DMARC |

De MX blijft van Microsoft: Postmark verstuurt alleen, de mailboxen van de winkel blijven waar ze zijn.

> **Toelichting:** Zie je in een DNS-zone van een klant een tweede TXT-record dat met v=spf1 begint, dan is dat een fout die je meteen meldt. Hetzelfde voor een DMARC-record met p=reject terwijl nog niet alle diensten DKIM hebben: dan weigeren ontvangers ook de mails van die diensten.

<!-- slide 49 -->

### Header check

Kies een scenario en vergelijk het domein van de From met de domeinen die SPF en DKIM checken.

- Postmark met domein: SPF pass, DKIM pass, DMARC pass. Inbox: SPF en DKIM slagen allebei, voor het domein van de From.
- Alleen een sender signature: SPF pass, DKIM none, DMARC fail. Spam: SPF slaagt, maar voor Postmark. Niets past bij fietsenmaes.be, dus DMARC faalt.
- Sendmail op de server: SPF none, DKIM none, DMARC fail. Spam: Geen enkele check slaagt. Gmail weigert zo een mail vaak zelfs.
- Phishing, na p=reject: SPF pass, DKIM pass, DMARC fail. Geweigerd: Beide checks slagen, maar voor een ander domein. De klanten van de winkel zien hem nooit.

> **Toelichting:** In Gmail zie je dezelfde regels bij Origineel weergeven. Bekijk ze eens bij een mail van je bank of van een grote webshop: bijna altijd drie keer pass.

<!-- slide 50 -->

### Zonder SPF, DKIM en DMARC

- Gmail wil sinds 2024 van elke afzender SPF of DKIM, en vanaf 5000 mails per dag alle drie. Outlook.com volgt sinds mei 2025.
- Zonder belandt je mail in spam, of weigert de server hem, bij Gmail met `550 5.7.26`
- Zonder DMARC kan iedereen phishing sturen met het adres van je klant
- Postmark toont "Delivered" zodra de ontvanger de mail aanvaardt. Dat hij daarna in spam belandt, zie je nergens.

> **Toelichting:** 550 5.7.26 is de code waarmee Gmail een mail weigert die niet geauthenticeerd is. Zie je hem in een bounce, kijk dan eerst naar SPF en DKIM.

<!-- slide 51 -->

### Nakijken

- **Origineel weergeven in Gmail**: Bij de drie puntjes van een mail. Je ziet SPF, DKIM en DMARC met pass of fail.
- **[mail-tester.com](https://www.mail-tester.com)**: Stuur een mail naar het adres dat je krijgt, en je krijgt een score op 10.
- **[DMARC Digests](https://dmarc.postmarkapp.com)**: Gratis tool van Postmark die de rapporten van rua leesbaar maakt.
- **[MXToolbox](https://mxtoolbox.com/SuperTool.aspx)**: Kijkt je SPF-, DKIM- en DMARC-records na, en zegt wat er mis is.

> **Toelichting:** Stuur na het instellen van DNS altijd een testmail naar een Gmail-adres en naar een Outlook-adres, en bekijk bij beide de headers. Mail-tester geeft een score, maar kijkt ook naar je inhoud, zoals verhoudingen tussen tekst en afbeeldingen. Een 10 op 10 is haalbaar voor een transactionele mail.

<!-- slide 52 -->

### Op welk domein checkt SPF?

- A. Het domein van de From
- B. Het domein van de Return-Path
- C. Het domein van de Reply-To
- D. Het domein in d= van de DKIM-handtekening

**Antwoord:** B. Het domein van de Return-Path

SPF kijkt naar de envelope sender, de Return-Path. De From lijkt logisch omdat de klant die ziet, maar die checkt DMARC, door te vergelijken met wat SPF en DKIM vonden. Daarom zet je bij Postmark een eigen Return-Path op pm-bounces.fietsenmaes.be: dan is SPF ook aligned met de From.

> **Toelichting:** Het tweede antwoord verwarren veel mensen met de From omdat dat het adres is dat ze zien. Onthoud het zo: SPF gaat over de envelope, het adres voor servers, en DMARC legt de link met de From die mensen zien.

<!-- slide 53 -->

## 7. Bounces en klachten

> **Toelichting:** Bounces en klachten negeren gaat een tijd goed, tot de reputatie van je stream zakt en ook de mails naar goede adressen in spam belanden. Een webhook die ze verwerkt is een paar uur werk en voorkomt dat.

<!-- slide 54 -->

### Wat terugkomt

|   | Wat er gebeurde | Postmark | Jouw app |
| --- | --- | --- | --- |
| **Hard bounce** | Het adres bestaat niet (meer) | Zet het adres op inactive | Markeer het adres, vraag een nieuw |
| **Soft bounce** | Mailbox vol, server tijdelijk plat | Adres blijft actief | Meestal niets |
| **Spam complaint** | Iemand klikte op "Dit is spam" | Deactiveert het adres voorgoed | Stop met mailen naar dat adres |

Veel bounces zegt aan ontvangers dat je slordig bent met je adressen. Spamklachten wegen nog zwaarder. Een hard bounce kan je in Postmark heractiveren, een spamklacht niet.

> **Toelichting:** Postmark deelt bounces op in veel types, zoals HardBounce, SoftBounce, Transient en SpamNotification. Voor je app volstaat bijna altijd het onderscheid tussen hard en de rest. In de docs van de bounce webhook staat de volledige lijst met TypeCode.

<!-- slide 55 -->

### Webhooks in Postmark

1. **Webhooks**: Je server, de stream outbound, tab Webhooks, Add webhook
2. **Url**: https://fietsenmaes.be/webhooks/postmark
3. **Events**: Bounce en Spam Complaint aanvinken
4. **Basic auth**: Een gebruiker en een wachtwoord, want Postmark ondertekent webhooks niet

- **[Les online betalingen](/nl/advanced-web-development/online-betalingen)**: Wat een webhook is, en waarom hij uit de CSRF-check moet
- **[Bounce webhook](https://postmarkapp.com/developer/webhooks/bounce-webhook)**: De payload die Postmark stuurt

> **Toelichting:** De webhook stel je per stream in. Heb je ook een broadcast stream, dan voeg je daar een aparte webhook toe, eventueel naar dezelfde url. Het veld MessageStream in de payload zegt dan van welke stream de melding komt.

<!-- slide 56 -->

### De route

- *routes/web.php*

  ```php
  Route::post(
      'webhooks/postmark',
      PostmarkWebhookController::class,
  );
  ```
- *bootstrap/app.php*

  ```php
  $middleware->preventRequestForgery(except: [
      'webhooks/postmark',
  ]);
  ```

Postmark stuurt geen CSRF-token mee, dus zonder uitzondering krijgt de webhook een 419. Anders dan bij Mollie zit alles al in de body: het type, het adres, je tag en je metadata. Je hoeft niets terug op te halen.

> **Toelichting:** Een route in routes/api.php heeft geen CSRF-check, dus daar is de uitzondering niet nodig. Het nadeel is dat routes/api.php pas bestaat na php artisan install:api, en dat je het prefix api/ in je url krijgt. Beide werken, kies wat al in je project zit.

<!-- slide 57 -->

### Alleen Postmark mag binnen

```php
public function __invoke(Request $request): Response
{
    $password = (string) $request->getPassword();

    abort_unless(hash_equals(config('services.postmark.webhook_password'), $password), 401);

    // daarna: de bounce of klacht verwerken, op de volgende slide
}
```

Iedereen kan een POST naar je url sturen. In Postmark zet je een gebruiker en een wachtwoord bij de webhook, en die komen mee als basic auth. `getPassword()` leest het wachtwoord uit de request. `hash_equals` vergelijkt altijd even lang, zodat niemand het wachtwoord letter per letter kan raden aan de tijd die het duurt.

> **Toelichting:** Postmark stuurt webhooks vanaf een vaste lijst IP-adressen. Wil je nog strenger zijn, laat dan alleen die toe, bovenop basic auth.

<!-- slide 58 -->

### Bounce of klacht verwerken

```php
$shouldBlock = match ($request->input('RecordType')) {
    'SpamComplaint' => true,
    'Bounce' => $request->input('Type') === 'HardBounce',
    default => false,
};

if ($shouldBlock) {
    User::where('email', $request->input('Email'))
        ->update(['email_blocked_at' => now()]);
}

return response('OK');
```

Een klacht blokkeert altijd. Een bounce alleen als hij hard is: een volle mailbox gaat vanzelf over.

Stuur daarna niets meer naar dat adres, en toon het in de admin. Dan weet de klant waarom iemand niets krijgt.

Antwoord met een 200. Anders probeert Postmark het opnieuw.

> **Toelichting:** Bij veel verkeer verwerk je de webhook in een queued job en antwoord je meteen met 200. Hoe dat werkt: zie de les over queues.

<!-- slide 59 -->

### Opdracht: Mail voor Fietsen Maes

Huiswerk in je eigen Laravel-project. Vast? Kijk in de [Laravel docs over Mail](https://laravel.com/docs/13.x/mail) of de [Postmark-docs over webhooks](https://postmarkapp.com/developer/webhooks/bounce-webhook).

1. Maak OrderConfirmedMail als Markdown mailable, met een pdf als bijlage en een lokale preview-route (slides 7 tot 18)
2. Vang hem op in Mailpit en stuur hem via de queue met afterCommit. Herd Pro mag ook. (slides 23 en 29)
3. Zet Postmark op met twee mailers, en test met test@blackhole.postmarkapp.com (slides 35 tot 37)
4. Verwerk bounces en spam complaints met een webhook achter basic auth (slides 55 tot 58)
5. Klaar als: de mail met bijlage in Mailpit staat, hij met je tag in Postmark Activity staat, en een test-bounce email_blocked_at zet

> **Toelichting:** Begin met de mailable en de preview-route: dan zie je meteen resultaat in je browser, nog zonder mail catcher of provider. Schrijf de pdf voor de bijlage niet zelf; een vaste pdf in storage/app volstaat voor de opdracht.

## Begrippen

- **Mailable**: Een class in app/Mail voor één soort mail, zoals OrderConfirmedMail. Wat erin moet, geef je mee in de constructor. (slide 6)
- **Envelope**: Wat envelope() teruggeeft: onderwerp, afzender, Reply-To, tags en metadata. Alles rond de mail, niet de inhoud. (slide 8)
- **Markdown mailable**: Een mailable met een view in Markdown en componenten zoals x-mail::button. Laravel maakt er responsive HTML en een tekstversie van, in het thema van je app. (slide 14)
- **x-mail::button**: Het Blade-component voor een knop in een Markdown mail, met een url en een kleur: primary, success of error. (slide 16)
- **Notification**: Eén gebeurtenis die over meerdere kanalen kan vertrekken: mail, database, Slack. Je stuurt hem met $user->notify(). Wachtwoord reset en e-mailverificatie zijn notifications. (slide 25)
- **ShouldQueue**: De interface die van een mailable een job maakt. Ook met send() gaat hij dan via de queue, en wacht de gebruiker niet op de mailprovider. (slide 23)
- **afterCommit**: Zet de mail pas op de queue als de databasetransactie klaar is. Anders kan de worker de mail sturen voor de order in de database staat. (slide 23)
- **Mail catcher**: Een lokale SMTP-server die elke mail opvangt en toont in plaats van hem te versturen, zoals Mailpit of de mail service van Herd Pro. (slide 29)
- **Mail::alwaysTo**: Stuurt elke mail naar één adres, en schrapt cc en bcc. Voor een staging-omgeving met een kopie van de echte klanten. (slide 30)
- **Transactionele mail**: Eén mail voor één persoon, omdat die iets deed: een bestelling, een wachtwoord reset, een factuur. (slide 34)
- **Broadcast mail**: Eén mail naar veel mensen tegelijk, zoals een nieuwsbrief. Bij Postmark in een eigen stream, met een verplichte uitschrijflink. (slide 34)
- **Message stream**: Een apart kanaal binnen een Postmark-server, met eigen IP-adressen en statistieken. outbound is de transactional stream, broadcasts de stream voor nieuwsbrieven. (slide 34)
- **Sender signature**: Eén afzenderadres dat je bij Postmark bevestigt door op een link in een mail te klikken. Snel, maar zonder DKIM en Return-Path op het domein van de klant. (slide 38)
- **Server API token**: Het token van één Postmark-server, dat als POSTMARK_API_KEY in .env komt. Niet te verwarren met het Account API token. (slide 35)
- **Return-Path**: Het adres waar bounces naartoe gaan, ook envelope sender genoemd. De gebruiker ziet het niet, maar SPF checkt het domein ervan. (slide 43)
- **SPF**: Een TXT-record dat zegt welke servers mogen mailen voor een domein. Het wordt gecheckt op het domein van de Return-Path, niet op dat van de From. (slide 44)
- **DKIM**: Een digitale handtekening over de headers en de body van een mail. De public key staat in DNS onder een selector, en d= in de handtekening zegt voor welk domein ze geldt. (slide 45)
- **DMARC**: Een TXT-record op \_dmarc dat zegt wat een ontvanger doet als SPF en DKIM geen van beide slagen voor het domein van de From: niets, spam of weigeren. (slide 46)
- **Alignment**: Het domein dat SPF of DKIM checkt, moet overeenkomen met het domein van de From. Standaard is dat relaxed: een subdomein zoals pm-bounces.fietsenmaes.be telt ook. (slide 47)
- **Deliverability**: Of een mail in de inbox landt in plaats van in spam. Een mail in spam geeft geen foutmelding, dus je merkt het pas als de klant belt. (slide 50)
- **Hard bounce**: Het adres bestaat niet (meer). Postmark zet het op inactive en stuurt er niets meer naartoe. (slide 54)
- **Soft bounce**: Een tijdelijk probleem, zoals een volle mailbox. Het adres blijft actief. (slide 54)
- **Spam complaint**: De ontvanger klikte op "Dit is spam". Postmark deactiveert het adres voorgoed, en te veel klachten kosten je de reputatie van je stream. (slide 54)

## Links en bronnen

### In de presentatie

- [Generating mailables](https://laravel.com/docs/13.x/mail#generating-mailables) (slide 6)
- [Writing mailables](https://laravel.com/docs/13.x/mail#writing-mailables) (slide 8)
- [Markdown mailables](https://laravel.com/docs/13.x/mail#markdown-mailables) (slide 13)
- [Previewing mailables](https://laravel.com/docs/13.x/mail#previewing-mailables-in-the-browser) (slide 18)
- [Queueing mail](https://laravel.com/docs/13.x/mail#queueing-mail) (slide 23)
- [Mail notifications in Laravel 13](https://laravel.com/docs/13.x/notifications#mail-notifications) (slide 26): MailMessage en toMail
- [Mailpit](https://mailpit.axllent.org/docs/install/) (slide 29): Installeren, SMTP op 1025 en de webinterface op 8025
- [Postmark driver](https://laravel.com/docs/13.x/mail#postmark-driver) (slide 33)
- [Postmark developer docs](https://postmarkapp.com/developer) (slide 33)
- [How does the account approval process work?](https://postmarkapp.com/support/article/1084-how-does-the-account-approval-process-work) (slide 36): Wat je kan sturen zolang je account in review staat
- [Tags and metadata](https://laravel.com/docs/13.x/mail#tags-and-metadata) (slide 39)
- [mail-tester.com](https://www.mail-tester.com) (slide 51)
- [DMARC Digests](https://dmarc.postmarkapp.com) (slide 51)
- [MXToolbox](https://mxtoolbox.com/SuperTool.aspx) (slide 51)
- [Bounce webhook](https://postmarkapp.com/developer/webhooks/bounce-webhook) (slide 55, slide 59): Payload, en basic auth omdat Postmark niet ondertekent
- [Mail in Laravel 13](https://laravel.com/docs/13.x/mail) (slide 59): Mailables, Markdown, queueing, previews, lokaal testen en de Postmark driver

### Bronnen

- [config/mail.php in de Laravel skeleton](https://github.com/laravel/laravel/blob/13.x/config/mail.php): De postmark mailer met message_stream_id
- [How to send transactional emails in Laravel](https://postmarkapp.com/blog/how-to-send-transactional-emails-with-laravel-php-framework): De gids van Postmark, met de Symfony transport
- [How to create and send through Message Streams](https://postmarkapp.com/support/article/how-to-create-and-send-through-message-streams): Transactional en broadcast, en hoe je een stream kiest
- [Message Streams API](https://postmarkapp.com/developer/api/message-streams-api): Stream IDs en verplichte uitschrijving op broadcast
- [How do I verify a domain?](https://postmarkapp.com/support/article/how-do-i-verify-a-domain): DKIM-record en Return-Path in Postmark
- [How do I add a custom Return-Path?](https://postmarkapp.com/support/article/910-how-do-i-add-a-custom-return-path): pm-bounces als CNAME naar pm.mtasv.net, voor SPF-alignment
- [Attachment and email size limits](https://postmarkapp.com/support/article/1056-what-are-the-attachment-and-email-size-limits): 10 MB per mail, bijlagen inbegrepen
- [Spam complaint webhook](https://postmarkapp.com/developer/webhooks/spam-complaint-webhook): Payload, en het adres blijft gedeactiveerd
- [Email sender guidelines](https://support.google.com/a/answer/81126): De eisen van Gmail sinds februari 2024
- [Outlook's new requirements for high-volume senders](https://techcommunity.microsoft.com/blog/microsoftdefenderforoffice365blog/strengthening-email-ecosystem-outlook%E2%80%99s-new-requirements-for-high%E2%80%90volume-senders/4399730): SPF, DKIM en DMARC bij Outlook.com sinds mei 2025
- [Herd Pro mail service](https://herd.laravel.com/docs/macos/herd-pro-services/mail): De .env voor de lokale SMTP-server van Herd
