diff --git a/apps/landing/src/content/changelog/1.11.0.md b/apps/landing/src/content/changelog/1.11.0.md
index a415e64..ba7813a 100644
--- a/apps/landing/src/content/changelog/1.11.0.md
+++ b/apps/landing/src/content/changelog/1.11.0.md
@@ -1,18 +1,22 @@
---
version: "1.11.0"
date: 2026-05-11
-title: "Première brique de la marque blanche"
+title: "Marque blanche : vos emails à votre image"
type: feature
highlights:
- - "Logo, nom expéditeur et couleurs custom dans les emails envoyés à vos clients"
- - "Les utilisateurs tatillons ont la main sur 10+ tokens visuels : bandeau, fonds, textes, bordures, boutons"
- - "Disponible sur le plan Business — l'interface self-service arrive dans la prochaine version"
+ - "Logo entreprise affiché en haut de chaque email envoyé à vos clients"
+ - "Nom expéditeur custom — vos clients voient `Cabinet Compta Martin`, pas Rubis"
+ - "10+ couleurs personnalisables : bandeau, fonds, textes, bordures, boutons, liens"
+ - "Aperçu live + bouton « envoyer un test » pour valider le rendu dans votre Gmail/Outlook"
+ - "Disponible sur le plan Business depuis `Paramètres → Marque`"
---
-Le plan Business gagne sa fonctionnalité marque blanche : vos emails de relance et de remerciement partent désormais avec **votre logo, vos couleurs et votre nom** à la place du branding Rubis.
+Le plan Business gagne une fonctionnalité demandée depuis longtemps : la marque blanche. Vos relances et vos remerciements partent désormais avec **votre logo, votre nom et vos couleurs** à la place du branding Rubis.
-Côté visible par votre client : il reçoit un email signé `Cabinet Compta Martin` (et non Rubis), avec votre logo en haut, vos couleurs sur les boutons, et votre signature en bas. Aucun « envoyé via Rubis » nulle part — c'est votre marque, du début à la fin.
+Côté visible par votre client : il reçoit un email signé `Cabinet Compta Martin` (et non Rubis), avec votre logo en haut, vos couleurs sur les boutons, et votre signature en bas. Aucun « envoyé via Rubis » nulle part. Pour lui, c'est votre boîte qui relance — pas une plateforme.
-Pour les utilisateurs tatillons, on a poussé la customisation loin : couleur du bandeau, du fond, des cartes, des textes principaux et secondaires, des bordures, des liens, du texte sur les boutons. Une dizaine de tokens visuels en tout — vous gardez la main sur tout l'email.
+Pour configurer, direction **Paramètres → Marque**. Deux couleurs principales suffisent pour 80 % du rendu (couleur primaire pour les boutons et accents, couleur du bandeau pour le header). Pour les utilisateurs tatillons, un panneau « Avancé » expose 7 couleurs supplémentaires : fond email, fond carte, texte principal, texte secondaire, bordures, liens, texte sur boutons.
-L'interface self-service pour configurer tout ça depuis vos réglages arrive dans la prochaine version. En attendant, les utilisateurs Business peuvent déjà demander à activer leur branding manuellement.
+L'aperçu en direct rend la même structure d'email que vos clients recevront. Pour valider le rendu exact dans leur boîte mail, un bouton « Envoyer un test » vous envoie un email réel avec les tokens courants — vous voyez dans votre Gmail/Outlook ce que verra votre client.
+
+Les emails internes Rubis (la confirmation que vous avez bien été payé avant qu'une relance ne parte) restent en branding Rubis — c'est de la métacommunication produit, pas un email destiné à vos clients.
diff --git a/apps/web/src/components/settings/BrandEmailPreview.tsx b/apps/web/src/components/settings/BrandEmailPreview.tsx
new file mode 100644
index 0000000..3e1505f
--- /dev/null
+++ b/apps/web/src/components/settings/BrandEmailPreview.tsx
@@ -0,0 +1,203 @@
+/**
+ * Aperçu live d'un email Rubis rendu avec les tokens courants. Pas le
+ * rendu exact de React Email (qui tourne côté serveur via @react-email/render),
+ * mais un mock fidèle qui réagit instantanément aux color pickers / upload.
+ *
+ * Pour le rendu RÉEL → bouton "Envoyer test à mon email" qui passe par
+ * l'endpoint `POST /api/v1/brand/test`.
+ *
+ * Le mock copie la structure HTML/CSS de `apps/api/app/mails/_layout.tsx`
+ * + `relance_email.tsx` : bandeau header, body avec texte pre-line, card
+ * récap facture, footer Rubis. Mêmes proportions, mêmes paddings, mêmes
+ * border-radius — pour qu'un utilisateur qui valide ici soit confiant que
+ * son vrai email rendra pareil.
+ */
+
+import type { BrandTokens } from "@/lib/brand";
+
+export function BrandEmailPreview({ tokens }: { tokens: BrandTokens }) {
+ return (
+
+ {/* Bandeau header */}
+
+ {tokens.logoUrl ? (
+
+ ) : (
+
+
+ ◆
+
+ {tokens.senderName}
+
+ )}
+
+ Facture F-2026-0042
+
+
+
+ {/* Body */}
+
+
+ Bonjour Christophe,{"\n\n"}Un petit rappel concernant la facture F-2026-0042 d'un montant de 1 240,00 €, échue depuis 8 jours.{"\n\n"}Merci pour votre attention,{"\n\n"}
+ {tokens.senderName}
+
+ Personnalisez le logo, le nom expéditeur et les couleurs de vos emails de relance.
+ Vos clients reçoivent un email à votre image — aucun branding Rubis n'apparaît.
+
+ La marque blanche fait partie du plan Business. Vous êtes actuellement sur le plan{" "}
+ {plan}. Passez en Business pour personnaliser le logo,
+ les couleurs et le nom expéditeur de vos emails de relance — vos clients ne sauront
+ jamais que Rubis automatise les envois.
+
+
+
+
+
+
+
+ );
+}
+
+/**
+ * Liste ordonnée des champs couleur exposés à l'utilisateur. La séparation
+ * "principal" / "avancé" est purement UX : on n'effraye pas le user moyen
+ * avec 9 couleurs en flat, et on laisse le tatillon ouvrir le panneau
+ * "Avancé" pour aller chercher chaque token.
+ */
+const COLOR_FIELDS_MAIN: Array<{
+ key: keyof BrandSettings;
+ tokenKey: keyof BrandTokens;
+ label: string;
+ hint: string;
+}> = [
+ {
+ key: "primaryColor",
+ tokenKey: "primary",
+ label: "Couleur primaire",
+ hint: "Boutons CTA, accents et liens par défaut.",
+ },
+ {
+ key: "bannerColor",
+ tokenKey: "banner",
+ label: "Bandeau header",
+ hint: "Fond du bandeau en haut de l'email (où apparaît votre logo).",
+ },
+];
+
+const COLOR_FIELDS_ADVANCED: Array<{
+ key: keyof BrandSettings;
+ tokenKey: keyof BrandTokens;
+ label: string;
+ hint: string;
+}> = [
+ {
+ key: "bodyBgColor",
+ tokenKey: "bodyBg",
+ label: "Fond email",
+ hint: "Couleur du fond global de l'email (autour de la carte).",
+ },
+ {
+ key: "cardBgColor",
+ tokenKey: "cardBg",
+ label: "Fond carte",
+ hint: "Couleur de la zone qui contient le texte du mail.",
+ },
+ {
+ key: "textColor",
+ tokenKey: "text",
+ label: "Texte principal",
+ hint: "Couleur du corps de texte de l'email.",
+ },
+ {
+ key: "textMutedColor",
+ tokenKey: "textMuted",
+ label: "Texte secondaire",
+ hint: "Footer, métadonnées et labels discrets.",
+ },
+ {
+ key: "borderColor",
+ tokenKey: "border",
+ label: "Bordures",
+ hint: "Lignes de séparation entre sections.",
+ },
+ {
+ key: "linkColor",
+ tokenKey: "link",
+ label: "Liens",
+ hint: "Couleur des liens cliquables (par défaut = couleur primaire).",
+ },
+ {
+ key: "buttonTextColor",
+ tokenKey: "buttonText",
+ label: "Texte sur boutons",
+ hint: "Si votre couleur primaire est claire, mettez du texte noir.",
+ },
+];
+
+function Editor() {
+ const { data, isPending } = useBrand();
+ const update = useUpdateBrand();
+ const uploadLogo = useUploadBrandLogo();
+ const deleteLogo = useDeleteBrandLogo();
+ const sendTest = useSendBrandTestEmail();
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [draft, setDraft] = useState({});
+ const fileInputRef = useRef(null);
+
+ // Sync draft → settings quand on charge la donnée OU après save.
+ useEffect(() => {
+ if (data) setDraft({ ...data.settings });
+ }, [data?.settings]);
+
+ if (isPending || !data) {
+ return (
+
+
+
+
Chargement…
+
+
+ );
+ }
+
+ // Tokens utilisés pour la preview : merge defaults ← draft (en suivant la
+ // sémantique "null = reset, undefined = pas touché"). On n'appelle pas
+ // resolveBrandTokens côté SPA, on fait un merge léger localement.
+ const previewTokens: BrandTokens = {
+ ...data.defaults,
+ senderName: draft.senderName ?? data.defaults.senderName,
+ logoUrl: draft.logoUrl ?? null,
+ primary: draft.primaryColor ?? data.defaults.primary,
+ banner: draft.bannerColor ?? data.defaults.banner,
+ bodyBg: draft.bodyBgColor ?? data.defaults.bodyBg,
+ cardBg: draft.cardBgColor ?? data.defaults.cardBg,
+ text: draft.textColor ?? data.defaults.text,
+ textMuted: draft.textMutedColor ?? data.defaults.textMuted,
+ border: draft.borderColor ?? data.defaults.border,
+ link: draft.linkColor ?? draft.primaryColor ?? data.defaults.link,
+ buttonText: draft.buttonTextColor ?? data.defaults.buttonText,
+ };
+
+ const isDirty = JSON.stringify(draft) !== JSON.stringify(data.settings);
+
+ const onSave = async () => {
+ // Diff draft − settings courants — on n'envoie que ce qui change.
+ const patch: Partial = {};
+ const allKeys = new Set([
+ ...Object.keys(draft),
+ ...Object.keys(data.settings),
+ ]) as Set;
+ for (const k of allKeys) {
+ if (k === "logoPath" || k === "logoUrl") continue; // gérés via upload/delete
+ const next = draft[k];
+ const prev = (data.settings as BrandSettings)[k];
+ if (next !== prev) {
+ patch[k] = next ?? null;
+ }
+ }
+
+ try {
+ await update.mutateAsync(patch);
+ toast.success("Marque enregistrée.");
+ } catch (err) {
+ if (err instanceof ApiError && err.fieldErrors) {
+ const first = Object.values(err.fieldErrors)[0]?.[0];
+ toast.error(first ?? err.message);
+ } else {
+ toast.error("Sauvegarde impossible. Réessayez.");
+ }
+ }
+ };
+
+ const onReset = () => {
+ // Pose tous les overrides à null = retour à la palette Rubis pour tout.
+ const reset: BrandSettings = {};
+ for (const f of [...COLOR_FIELDS_MAIN, ...COLOR_FIELDS_ADVANCED]) {
+ reset[f.key] = null;
+ }
+ reset.senderName = null;
+ setDraft({ ...draft, ...reset });
+ toast.info("Couleurs et nom expéditeur réinitialisés. N'oubliez pas d'enregistrer.");
+ };
+
+ const onUploadClick = () => fileInputRef.current?.click();
+
+ const onFileChosen = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ try {
+ await uploadLogo.mutateAsync(file);
+ toast.success("Logo mis à jour.");
+ } catch (err) {
+ const msg = err instanceof ApiError ? err.message : "Upload impossible.";
+ toast.error(msg);
+ } finally {
+ // Reset l'input pour qu'un upload du même fichier re-déclenche change.
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+ };
+
+ const onDeleteLogo = async () => {
+ try {
+ await deleteLogo.mutateAsync();
+ toast.success("Logo retiré.");
+ } catch {
+ toast.error("Impossible de retirer le logo.");
+ }
+ };
+
+ const onSendTest = async () => {
+ if (isDirty) {
+ toast.info("Enregistrez avant d'envoyer un test (le mail utilise les valeurs sauvegardées).");
+ return;
+ }
+ try {
+ const r = await sendTest.mutateAsync();
+ toast.success(`Test envoyé à ${r.sentTo}.`);
+ } catch {
+ toast.error("Envoi impossible. Réessayez.");
+ }
+ };
+
+ return (
+
+
+
+
+ {/* ============ Form ============ */}
+
+ {/* Logo */}
+
+ Logo
+
Image affichée en haut de l'email
+
PNG, JPG, WebP ou SVG. 1 Mo max. Hauteur affichée 32 px.