# Notifications and e-mail

Every automatic mail is driven by a **template** that a business can edit in the admin
(**Meldingen**). The service renders the mail when the event happens, puts it in an **outbox**, and
sends it from there in the background with retries. Nothing is sent directly
from the request that created the booking — mail failures can never fail a booking.

## Contents

- [Lifecycle](#lifecycle)
- [Types](#types)
- [Recipients](#recipients)
- [Timing](#timing)
- [Template language](#template-language)
- [Codes](#codes)
- [Attachments and reply-to](#attachments-and-reply-to)
- [One mail per order, one per booking](#one-mail-per-order-one-per-booking)
- [Deferred mail for online payments](#deferred-mail-for-online-payments)
- [Delivery, retries and the log](#delivery-retries-and-the-log)
- [Background processing](#background-processing)
- [Admin reference](#admin-reference)
- [Sender and links](#sender-and-links)
- [Not implemented](#not-implemented)

## Lifecycle

```
event (booking, status change, reminder window, waitlist offer, …)
   │
   ├─ match active templates of that type (status filter, service filter)
   ├─ resolve recipients (customer / staff / admins / extra addresses)
   ├─ render subject + body per recipient  ← template codes, business time zone, locale
   ├─ dedupe (a unique dedupe key per recipient + event) and add to the outbox
   ▼
outbox (status pending → sending → sent | failed)
   ▲
   └─ every minute: send what is due → retry with backoff
```

Rendering happens at **enqueue** time, so the mail shows the data as it was at that moment.

## Types

| Type | UI label | Fires when |
|---|---|---|
| `new_booking` | Nieuwe afspraak | a booking/order is created (immediately for local/free orders; after payment for Mollie orders; also on waitlist joins and package purchases the corresponding type is used instead) |
| `status_changed` | Statuswijziging | a booking's status changes (admin action, cancellation, refund-rejection, automatic status change) |
| `reminder` | Herinnering | the reminder window for an upcoming appointment opens |
| `waitlisted` | Wachtlijst | a customer joins a waiting list |
| `waitlist_offer` | Plek vrij (wachtlijst) | a seat frees up and the customer gets a claim link |
| `package_new` | Nieuw pakket | a package is bought or created (mail contains the personal planning link) |
| `event_tickets` | Tickets | an event order is placed (a "codes follow after payment" variant) and again once the order is approved with the actual codes |

New accounts get a default template set for every type on signup (Dutch), so mail works out of the box.

## Recipients

Each template has four recipient switches:

| Switch | Who | Reply-To |
|---|---|---|
| **Klant** (customer) | the booking customer's e-mail | your **Antwoordadres** (`reply_to`), or the platform sender when empty |
| **Medewerker** (staff) | the staff member of the appointment (skipped when archived); for packages the package staff | the customer's e-mail |
| **Beheerders** (admins) | every member of the account with role owner or admin | the customer's e-mail |
| **Andere adressen** (custom) | the newline-separated addresses in the template | the customer's e-mail |

Sender: display name = your **Naam afzender** (or company name), address = the platform's
`MAIL_FROM`. At least one recipient must be enabled; custom recipients require at least one address.

## Timing

**Status filter** (`Bij status`): send only when the booking has that status; *Elke status* matches
everything. Note that *Elke status* in a **reminder** never includes cancelled or rejected bookings.

**Service filter**: all services, or a ticked subset.

**Reminder timing** (only for `reminder`):

- *Een aantal uur van tevoren* — send N hours before the appointment start (default 24; 0 means at
  the appointment time). Evaluated with a tolerance window: your
  **Verzendmarge herinneringen (uur)** (default 2) widens the match, so a reminder is not skipped after a
  short processing delay.
- *Op een vast uur* — send at a fixed local hour (0–23, default 18) a number of days before the
  appointment date (0–30, default 1). "0 = op de dag van de afspraak zelf".

Reminders dedupe on a key that includes the appointment start time, so **rescheduling re-arms the
reminder** while nothing is sent twice for the same booking.

## Template language

Placeholders and conditionals in both subject and body:

```
{client_name},
Je afspraak voor {service_name} is bevestigd op {appointment_date} om {appointment_time}.

{#if extras}
Extra's: {extras}
{/if}
{#if number_of_persons > 1}
We reserveren {number_of_persons} plaatsen.
{/if}
```

Rules:

- `{code}` — unknown or empty codes render as empty text.
- `{#if code}…{/if}` — block, may be nested. A newline directly after a block tag is swallowed.
- Comparisons: `{#if code == value}`, `!=`, `>`, `>=`, `<`, `<=`, `empty` (empty/absent), `!empty`.
  Values are compared as strings/numbers; missing codes are honest about being empty.
- No loops: there is no `{#each}`, so multi-item orders use the pre-joined `{appointment_schedule}` /
  `{extras}` / `{ticket_codes}` codes instead.
- **HTML mode** (the body of HTML mails): inserted values are HTML-escaped, newlines become `<br>`, and
  blank lines become paragraphs. A bad placeholder can therefore never inject HTML into a mail.
  Subjects always use plain text mode.

## Codes

`{code}` list as offered in the editor (Dutch labels in the admin):

**Bedrijf** — `company_name`, `company_phone`, `company_address`, `company_website`
**Klant** — `client_name`, `client_email`, `client_phone`
**Afspraak** — `appointment_date` ("dinsdag 14 oktober 2026"), `appointment_time`,
`appointment_end_time`, `appointment_notes` (the customer's remark), `booking_number`,
`number_of_persons`, `extras` ("Haarmasker (2×), Föhnen"), `extras_total_price`, `order_count`,
`appointment_schedule` (one line per appointment of the order), `online_meeting_url` and
`online_meeting_join_url` (empty unless the appointment has an online meeting), `series_count`,
`status` (Dutch label),
`internal_note` (only for staff/admin recipients), `cancel_appointment_url` (`/b/<token>`),
`cancel_order_url` (`/o/<order token>`),
`cancellation_reason` (currently always empty)
**Dienst** — `service_name`, `service_info`, `service_price`, `service_duration` ("1 uur 30 min"),
`category_name`, `fields` (the booking's answers to the service's Velden, one "Vraag: antwoord" line
per answer; a per-person answer gets one line per person. Empty without answers. A field that was
deleted later keeps showing its old answer, labelled with the field's id. A **Bestand** answer shows
the file name only: its download link is signed and short-lived, and mails are kept.)
**Medewerker** — `staff_name`, `staff_email`, `staff_phone`
**Betaling** — `total_price` (empty when free), `amount_paid`, `amount_due`,
`payment_type` (Ter plaatse / Online / Gratis), `payment_status` (Dutch), `coupon`, `total_tax`
**Wachtlijst** — `waitlist_url` (only `waitlist_offer`)
**Pakket** — `package_url`, `package_name`, `package_size`, `package_remaining`, `package_expires`
(only `package_new`)
**Evenement** — `event_title`, `event_start_date`, `event_start_time`, `event_end_time`, `event_info`,
`attendees_count`, `ticket_codes` (before payment: "Je codes krijg je na betaling."), `order_total`
(only `event_tickets`)

Which codes a template may use depends on its type: package and ticket templates have no appointment,
so they only offer company/client codes plus their own group.

## Attachments and reply-to

**Agenda-bestand (.ics) meesturen** attaches a standards-compliant calendar file (UTC times, escaped,
folded at 75 octets) for booking-based mails. It follows the appointment's current time; rescheduling
does not rewrite already-sent mails.

## One mail per order, one per booking

A single checkout can contain a cart, chain parts, compound/collaborative parts and a whole recurring
series. To keep this readable:

- **Customer, admins and custom recipients** get **one** `new_booking` mail per **order**, using the
  first appointment's codes plus `{appointment_schedule}` and `{order_count}`.
- **Staff** get one mail per appointment of their own.
- `status_changed` and `reminder` stay **per booking** — every cancelled occurrence gets its own mail.

## Deferred mail for online payments

For Mollie orders nothing is mailed at order time: the customer should not receive "your appointment is
confirmed" before paying. The confirmation mail is queued when the payment completes (webhook, return
poll, or the expiry check noticing the payment went through). Local and free orders are mailed
immediately. Ticket orders are the exception: they get a "codes follow after payment" mail right away,
and the codes mail once approved.

## Delivery, retries and the log

- Queued mail is sent within about a minute.
- Retry backoff after a failed send: 1 min, 5 min, 30 min, 2 h, 12 h. After 5 attempts the row becomes
  `failed` (it stays visible in the log with the error text).
- Mail stuck in *Bezig* (sending) for more than 10 minutes is retried automatically.
- **Meldingen → Verzendlog** shows every mail: recipient, subject, status (Verstuurd / In wachtrij /
  Bezig / Mislukt) and attempt count, filterable by status.
- **Test versturen** renders the saved template with sample data and sends it to the signed-in user —
  use it after every template change.

## Background processing

Everything timed runs as one scheduled pass, once a minute: queue the reminders that are due,
expire unpaid payments, apply the automatic status change, send the waiting-list offers, and deliver
queued mail. The service runs this itself — nothing to configure — but it is worth knowing because it
explains the timing you and your customers see:

- A reminder goes out at its chosen moment, with the tolerance window above absorbing a short delay.
- An online payment that is never completed expires after your **Betaaltermijn**; the slot
  then frees up. Until that moment it stays reserved.
- A waiting-list offer is mailed within a minute of a seat freeing up.

## Admin reference

**Meldingen** (`/app/notifications`) — grouped by type with an Aan/Uit switch per template. The editor
fields: Naam, Soort, Actief, Ontvangers (Klant / Medewerker / Beheerders / Andere adressen), Bij status,
Diensten (alle or a selection), reminder timing, Onderwerp, Bericht, Agenda-bestand (.ics) meesturen,
and the code table. Existing templates also have **Test versturen** and **Verwijderen**.

**Verzendlog** (`/app/notifications/log`) — the outbox, newest first, 50 per page.

Relevant settings: **Instellingen → Bedrijf en e-mail** (Naam afzender, Antwoordadres,
Verzendmarge herinneringen, Annuleren tot) and **Instellingen → Algemeen** (Tijdzone — all mail times
use it).

## Sender and links

The display name comes from **Instellingen → Bedrijf en e-mail** (Naam afzender), replies go to the
**Antwoordadres**, and the links in mails point at the pages this service serves (`/b/…`, `/p/…`,
`/w/…`, `/o/…`). Mail is sent from the platform's own sending domain; you do not configure it.

## Not implemented

- SMS, WhatsApp and voice notifications (only e-mail exists).
- `cancellation_reason` is always empty (no reason is captured on cancellation).
- No `{#each}` loops, no attachments other than .ics, no birthday/last-appointment campaigns.
- No per-recipient language: templates are sent in the language they are written in (Dutch by default).
