Coloriginz Design Systemshadcn custom registry

@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

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