@col/email-shell
Versie: 1.0.0 Toegevoegd: 2026-08-05
Herkomst
Drie apps hadden elk een derde van het antwoord. De sjabloonopbouw — tabel-layout,
CID-logo, <o:OfficeDocumentSettings>, escapeHtml() — komt uit
growerportal2/src/lib/email-templates.ts (831 regels), waar dat deel het beste
was uitgewerkt. De vorm van Mail (to, subject, text, html,
attachments) is bewust gelijk aan wat floriday api/src/lib/mail.ts al
verwachtte, zodat een transport-item er later op aansluit zonder herontwerp. De
merkparametrisering — een EmailBrand met accent, contrastkleur en merkregel,
los van de romp — komt uit supplier-onboarding-vercel/src/lib/email.ts (1172
regels, vier merken × vier talen × negentien mailtypen), ook al gebruikte dat
bestand zelf nog geen echte merktokens: drie apps hadden drie knopkleuren
(groen, zwart, blauw) en geen enkele kwam uit brand-tokens.
Zie docs/specs/2026-08-05-v7-email-ontwerp.md voor de volledige analyse en de
genomen beslissingen.
Waarvoor
Bouwt een merkgebonden transactionele e-mail — uitnodiging, wachtwoord
instellen, bevestiging, passcode — uit een lijst blokken, en levert een kant-en-
klaar Mail-object met HTML én platte tekst uit dezelfde bron. Het item
verstuurt niets zelf; dat blijft aan het transport van de app.
Gebruik het niet voor nieuwsbrieven of iets met een kolommenlayout. De vaste kaart van 560px met één kolom blokken is te smal daarvoor, en er zit geen grid-systeem in.
Wie alleen de romp wil — eigen blokken, eigen zinnen — gebruikt dit item. Wie de
kant-en-klare wachtwoord-instellen- en wachtwoord-vergeten-mails wil, installeert
daarnaast @col/email-templates.
Wat je app moet leveren
EMAIL_SHELL_VERSION
Uit email-types.ts: EMAIL_SHELL_VERSION: string. Welke versie in een app
draait, achterhaal je met grep -rn EMAIL_SHELL_VERSION src/; bijwerken gaat
met npx shadcn add @col/email-shell --overwrite.
EmailBrand
| Veld | Type | Betekenis |
|---|---|---|
name |
string |
Merknaam, komt terug in het alt-attribuut van het logo |
logo |
EmailLogo |
Zie hieronder |
accent |
string (#rrggbb) |
Knopvlak. Bij meerdere merken: emailAccents[label].accent uit @col/brand-tokens |
accentContrast |
string (#rrggbb) |
Tekstkleur op de knop |
rule |
string (#rrggbb) |
Streepje van 4px bovenaan de kaart |
footerText |
string |
Merkregel onderaan, bv. "Coloríginz — OZ Import BV, Aalsmeer" |
EmailLogo extends EmailAttachment: filename, content: Buffer, cid, plus
width en height (pixels, worden letterlijk in de <img> gezet).
EmailAttachment is de vorm die nodemailer verwacht — andere verzenders
(Resend-API, SES) nemen dezelfde velden of vragen een triviale vertaling.
Waar logo.content vandaan komt, is aan jou — en dat is precies waar het mis
kan gaan. Embed het logo als base64-constante in code en maak daar bij het
versturen een Buffer van: Buffer.from(LOGO_BASE64, 'base64').
supplier-onboarding-vercel/src/lib/logo-base64.ts is het werkende precedent.
Lees het niet met fs uit public/ op het moment dat je verstuurt: Vercel
serverless functions kunnen public/ niet betrouwbaar van de schijf lezen, dus
fs.readFileSync('public/brand/logo.png') op verzendmoment levert op productie
een mail zonder logo — en dat merk je niet in de preview (die gebruikt een
placeholder) en niet in de tests, alleen bij een echte ontvanger.
@col/brand-assets zet logo's in public/brand/ neer voor de webinterface;
dat is een andere taak en hoort niet aan e-mailverzending gekoppeld te worden.
Dit is dezelfde fout als het startpunt van dit hele project:
supplier-onboarding-vercel laadde zijn e-maillogo ooit van een externe URL,
met hetzelfde gevolg — werkt lokaal, faalt bij de ontvanger.
Alle drie de hexvelden (accent, accentContrast, rule) worden door
buildEmail gecontroleerd met assertHexColour, en die gooit een Error
bij alles wat geen #rrggbb is — geen oklch(), geen Tailwind-klasse. Dat is
bewust streng: een ongeldige kleur die niet direct wordt afgevangen, wordt pas
zichtbaar bij een ontvanger, als een zwarte knop in Outlook — en dan is de
oorzaak allang uit beeld.
EmailBlock — zes varianten
| Kind | Velden | Rendert als |
|---|---|---|
heading |
text |
<h1>, één keer per bericht |
paragraph |
text |
<p> |
button |
label, href |
Outlook-vaste VML-knop plus gewone <a>, zie emailButton |
code |
value, label? |
Groot, vet, gespatieerd (voor een passcode of referentie) |
note |
text |
Kleine, gedempte regel, bv. een geldigheidsdatum |
raw |
html, text |
Noodluik voor iets wat de andere blokken niet dekken. Beide varianten zijn verplicht — zo kan de platte tekst nooit ontbreken |
In een raw-blok schrijf je de HTML zelf, dus daar geldt de
escaping-garantie uit "Bewuste keuzes" niet — er zit geen renderer meer
tussen jouw tekst en de uitvoer. Zet gebruikersinvoer in zo'n blok altijd
door escapeHtml uit hetzelfde item (email-html.ts), dat is precies de
stap die in supplier-onboarding-vercel ontbrak.
buildEmail gooit als blocks leeg is: een mail zonder inhoud is altijd een
programmeerfout, geen geldige toestand.
buildEmail
function buildEmail(input: {
to: string
subject: string
brand: EmailBrand
blocks: EmailBlock[]
strings?: EmailShellStrings // standaard emailShellStringsNl
}): Mail
Geeft een Mail terug — { to, subject, text, html, attachments } — die je
zelf verstuurt met je eigen transport (nodemailer, Resend, SES). attachments
heeft altijd precies één element: het logo als CID-bijlage. Geen bijlagen
toevoegen kan niet via dit item; wie meer bijlagen nodig heeft, voegt ze zelf
toe aan de teruggegeven attachments-array voordat hij verstuurt.
EmailShellStrings bevat twee velden: lang (voor het lang-attribuut van
<html>) en footerNote (de vaste slotzin, bv. "Dit bericht is automatisch
gegenereerd."). Het item levert emailShellStringsNl en emailShellStringsEn;
heeft je app al een vertaalsysteem, geef dan je eigen object mee en negeer deze
twee.
Voorbeeld
import { buildEmail } from '@/components/email/email-shell'
import { emailShellStringsEn } from '@/components/email/email-strings'
const mail = buildEmail({
to: 'supplier@example.com',
subject: 'Complete your registration',
brand: {
name: 'Coloríginz',
logo: { filename: 'logo.png', content: logoBuffer, cid: 'brand-logo', width: 140, height: 40 },
accent: '#006799',
accentContrast: '#ffffff',
rule: '#0098da',
footerText: 'Coloríginz — OZ Import BV, Aalsmeer',
},
blocks: [
{ kind: 'heading', text: 'Welcome' },
{ kind: 'paragraph', text: 'Please complete your supplier registration.' },
{ kind: 'button', label: 'Complete registration', href: 'https://onboarding-portal.apps.coloriginz.com/...' },
{ kind: 'note', text: 'This link is valid for 14 days.' },
],
strings: emailShellStringsEn,
})
// mail.html, mail.text, mail.attachments -> door naar je eigen transport
Bestanden
| Bestand | Landt in | Soort |
|---|---|---|
email-types.ts |
components/email/ |
beheerd |
email-html.ts |
components/email/ |
beheerd |
email-strings.ts |
components/email/ |
beheerd |
email-button.ts |
components/email/ |
beheerd |
email-shell.ts |
components/email/ |
beheerd |
Geen registryDependencies. Kleuren komen als gewone strings binnen via
EmailBrand, niet als import uit @col/brand-tokens — zie Bewuste keuzes.
Gebruikt door
| App | Sinds versie | Opmerkingen |
|---|---|---|
floriday-middleware |
1.0.0 | De uitnodigingsmail. Eén merk, dus EmailBrand staat als vaste waarde in src/features/auth/emails/brand.ts met de hexwaarden van COLORIGINZ overgeschreven — brand-tokens is er niet geïnstalleerd. Logo als base64-constante, gegenereerd door scripts/genereer-email-logo.mjs. npm run testmail schrijft een .eml zoals build-test-emails.mjs hier. |
Bewuste keuzes
- Blokken in plaats van een HTML-string.
buildEmailrendert zowel de HTML als de platte tekst uit dezelfdeblocks-array. Dat lost twee dingen tegelijk op die in de bronapps stuk waren: escaping kan niet vergeten worden (de renderer escapet elk tekstveld, niet de aanroeper), en de platte tekst kan niet uit de pas lopen met de HTML — bij losse strings moet elke wijziging twee keer gebeuren, en dat ging in de praktijk een keer mis (supplier-onboarding interpoleerde bedrijfsnamen ongefilterd in e-mail-HTML).blokHtmlenblokTeksthebben bewust geendefault-case in hunswitch: een nieuweEmailBlock-variant moet in TypeScript stuklopen op beide functies in plaats van stilzwijgend een lege string op te leveren en de twee uit elkaar te laten lopen. - De VML-knop met een vaste breedte van 260px. Outlook op Windows negeert
border-radiusenbackground-colorop een<a>-tag; zonder het<v:roundrect>-blok krijg je daar een gekleurde tekstregel in plaats van een knop. VML kent geen auto-breedte — de breedte moet je zelf vastzetten. De twee varianten (VML voor Outlook, een gewone<a>voor de rest) sluiten elkaar uit via<!--[if mso]>/<!--[if !mso]>conditional comments; er is nooit meer dan één zichtbaar. Gevolg: een lang knoplabel past niet automatisch en kan in Outlook afgekapt worden — dat moet je handmatig controleren bij een nieuw label (zie Bekende beperkingen). accentis brand-700, niet brand-500. Gemeten contrast van witte tekst op brand-500: COLORIGINZ 3,2 — PFC 2,6 — FFS 4,3 — MPO 4,7. Drie van de vier merken halen de 4,5:1 van WCAG AA niet, en knoptekst van 15px vet telt niet als "grote tekst" onder die norm. Met brand-700 wordt dat 6,2 — 5,2 — 7,9 — 8,4, en dan kanaccentContrastvoor elk merk gewoon wit blijven. Prijs: het PFC-goud wordt in de knop donkerder (#806b24) dan in het logo — een bewuste ruil van merkgetrouwheid tegen leesbaarheid. Het streepje bovenaan (rule) draagt geen tekst en houdt daarom wél brand-500.- Geen
registryDependenciesop@col/brand-tokens. Een import uit@col/brand-tokenszou in de consumer op@/components/brand/email-colorsmoeten wijzen, en dat pad bestaat in déze repo niet — typecontrole en tests zouden er meteen op breken. Belangrijker: de eerste consument (floriday-middleware) bedient één merk en heeftbrand-tokenshelemaal niet nodig. Kleuren komen daarom als gewone strings binnen viaEmailBrand. Multi-merk-apps vullen die uitemailAccents[label](@col/brand-tokensv1.1.0), een app met één merk zet er zijn eigen hexwaarde neer. Dat maaktemail-shellbruikbaar zonderbrand-tokenste installeren.
In clients gecontroleerd
| Client | Datum | Resultaat |
|---|---|---|
| Outlook desktop (Windows) | 2026-08-06 | Goed. De VML-knop rendert als knop, niet als gekleurde tekstregel. Logo komt door als CID-bijlage. |
| Gmail (web) | — | Nog niet gedaan. Gmail importeert geen losse .eml, dus dit vraagt een echte verzending; doe het bij de migratie van floriday-middleware. |
| Apple Mail, donkere modus | — | Nog niet gedaan. |
Testmails maak je met node scripts/build-test-emails.mjs; die schrijft per merk
een .eml in tmp/ met het echte logo als bijlage. Vul deze tabel aan zodra een
client erbij komt — dit is de enige plek waar staat wat er daadwerkelijk
gecontroleerd is, en de reden dat de VML-knop bestaat is precies dat je het niet
kunt afleiden uit de code.
Bekende beperkingen
- Donkere modus is niet oplosbaar, niet alleen niet opgelost. Apple Mail en
Outlook.com draaien kleuren om en negeren
prefers-color-schemein mail, ongeacht wat de HTML zegt.meta name="color-scheme" content="light"plus een expliciete achtergrondkleur op elk element (wat dit item al doet) is het enige dat helpt; een volledig consistent donker uiterlijk in álle clients is met e-mail-HTML niet te garanderen. - De VML-knopbreedte is vast op 260px. Een lang label wordt in de Outlook- variant niet automatisch smaller of breder en kan afgekapt worden. Controleer dit handmatig bij een nieuw label of een nieuwe taal.
- Het item verstuurt niets.
buildEmailis puur — geen I/O, geen netwerk — en geeft alleen eenMail-object terug. Verzenden, retries en providerkeuze zijn aan de app. - De garantie dat HTML en platte tekst niet uit de pas lopen geldt per
bloksoort, niet per veld. Een nieuwe
EmailBlock-variant breekt de build in beide renderers (zie Bewuste keuzes), maar een nieuw optioneel veld op een bestaande variant — bijvoorbeeld eensublabelopheading— doet dat niet. TypeScript dwingt dan niet af datblokTeksthet nieuwe veld ook gebruikt; dat blijft mensenwerk. assertSafeUrlenhrefAttributeworden niet door het typesysteem uit elkaar gehouden. Beide accepteren en retournerenstring.assertSafeUrlvalideert het protocol en geeft de URL onbewerkt terug — bedoeld voor het text/plain-deel.hrefAttributedoet hetzelfde en escapet daarna — bedoeld voor eenhref-attribuut in HTML. Wie de onbewerkte vorm (assertSafeUrl) in een href zet, krijgt geen compiler- of lintwaarschuwing; alleen de functienamen en de doc-comments inemail-html.tswijzen op het verschil, en die overleven een--overwriteniet als iemand het bestand lokaal aanpast. (Uit de review van taak 2: een branded type is voor een bestand van zestig regels overdreven, een regel documentatie niet — vandaar dat het bij deze waarschuwing blijft in plaats van een sterker typeonderscheid.)