feat(mail): templates HTML React Email + brand "Rubis sur l'ongle"
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 1m1s
Build & Deploy API / build-and-deploy (push) Successful in 2m3s

Templates HTML stylés DA Rubis pour les 2 emails sortants — fini le
plain text moche.

apps/api/app/mails/
  ├── _brand.ts          : tokens couleur + spacing partagés
  ├── _layout.tsx        : squelette commun (header rubis-deep + footer)
  ├── checkin_email.tsx  : email envoyé À L'USER avec 2 boutons CTA
  │                        Oui (rubis primary) / Non (outlined)
  └── relance_email.tsx  : email envoyé AU CLIENT, body texte du plan
                           + card récap (numéro, montant, échéance,
                           badge retard rubis-deep)

Stack :
  - @react-email/components + @react-email/render
  - Tous les styles inline (compatible Gmail / Outlook / Apple Mail)
  - HTML + plain text en fallback (anti-spam, accessibility)

mail_dispatcher.ts :
  - sendRelanceEmail : .html(rendered) + .text(body)
  - sendCheckinEmail : .html(rendered) + .text(body)
  - daysLate calculé via clock.now (démo-aware)

send_test_email :
  - Nouveau flag --template=checkin (default) | relance | plain pour
    tester chaque rendu via Mailpit sans créer de vraie facture.

Brand & landing :
  - "Rubis Sur l'Ongle" → "Rubis sur l'ongle" partout (config, mail,
    PDF, Stripe appInfo)
  - Nouvelle env var LANDING_URL (default https://rubis.arthurbarre.fr)
  - Footer email rend "Rubis sur l'ongle" comme <a> rubis cliquable
    vers la landing — l'user qui reçoit le mail connaît la marque
    derrière l'envoi
  - .env.example mis à jour avec LANDING_URL pour les autres devs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-07 18:10:27 +02:00
parent 87c6f49692
commit ff8fe64be2
13 changed files with 1139 additions and 32 deletions

View File

@ -52,7 +52,7 @@ S3_FORCE_PATH_STYLE=true
# Mail (Resend par défaut, Mailpit en fallback dev via MAIL_DRIVER=smtp)
#--------------------------------------------------------------------
MAIL_FROM_ADDRESS=rubis@arthurbarre.fr
MAIL_FROM_NAME=Rubis Sur l'Ongle
MAIL_FROM_NAME=Rubis sur l'ongle
MAIL_DRIVER=resend
RESEND_API_KEY=
# Fallback Mailpit (si MAIL_DRIVER=smtp)
@ -70,6 +70,12 @@ MISTRAL_API_KEY=
#--------------------------------------------------------------------
WEB_URL=http://localhost:5173
#--------------------------------------------------------------------
# Landing publique — lien dans le footer des emails ("Rubis sur l'ongle"
# pointe vers ce domaine).
#--------------------------------------------------------------------
LANDING_URL=https://rubis.arthurbarre.fr
#--------------------------------------------------------------------
# Auth (refresh tokens)
#--------------------------------------------------------------------

View File

@ -0,0 +1,40 @@
/**
* Tokens de marque partagés par tous les templates email.
*
* On reste sur des hex en dur (pas de CSS vars) parce que les clients mail
* (Gmail, Outlook) ne supportent pas les `--var` dans les `style="..."`.
* Tous les styles sont inline pour la même raison.
*/
export const BRAND = {
// Palette Rubis (cf. CLAUDE.md → marque)
rubis: '#9F1239',
rubisDeep: '#771328',
rubisLight: '#C9415C',
rubisGlow: '#FBE4EA',
cream: '#FAF7F2',
cream2: '#F5EFE7',
ink: '#1A1410',
ink2: '#4F4640',
ink3: '#8A7F76',
line: '#E8E0D6',
white: '#FFFFFF',
// Typo — on s'appuie sur les fallbacks system-ui pour la portabilité mail.
fontBody:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Helvetica, Arial, sans-serif",
// Radius cohérents avec l'app
radiusButton: '6px',
radiusCard: '14px',
} as const
/** Wrappers d'unités inline-friendly pour pas faire de gros calculs côté <style>. */
export const sp = {
xs: '4px',
sm: '8px',
md: '12px',
lg: '16px',
xl: '24px',
xxl: '32px',
} as const

View File

@ -0,0 +1,167 @@
/**
* Squelette commun aux 2 templates Rubis : header rubis-deep avec brand
* + container cream + footer "Émis via Rubis sur l'ongle" (cliquable
* vers la landing publique).
*
* Approche : on stylé en inline via les `style` props que React Email
* convertit en HTML inline (compatible tous mail clients y compris
* Outlook). Pas de classes Tailwind ici risquées en mail.
*/
import * as React from 'react'
import {
Html,
Head,
Preview,
Body,
Container,
Section,
Row,
Column,
Text,
Link,
} from '@react-email/components'
import { BRAND, sp } from './_brand.js'
type LayoutProps = {
/** Aperçu dans la liste mail (Gmail preview text). */
preview: string
/** Nom commercial affiché dans le header (vendeur ou "Rubis"). */
brandName: string
/** Sous-titre header (ex: numéro de facture, date). Optionnel. */
brandSubtitle?: string | null
/** URL de la landing publique — lien dans le footer ("Rubis sur l'ongle"). */
landingUrl?: string
children: React.ReactNode
}
export function EmailLayout({
preview,
brandName,
brandSubtitle,
landingUrl,
children,
}: LayoutProps) {
return (
<Html lang="fr">
<Head />
<Preview>{preview}</Preview>
<Body style={bodyStyle}>
<Container style={containerStyle}>
{/* Bandeau rubis-deep avec gem ◆ + brand */}
<Section style={headerStyle}>
<Row>
<Column style={{ verticalAlign: 'middle' }}>
<Text style={headerBrandStyle}>
<span style={gemStyle}></span>
{brandName}
</Text>
{brandSubtitle ? (
<Text style={headerSubtitleStyle}>{brandSubtitle}</Text>
) : null}
</Column>
</Row>
</Section>
{/* Corps de l'email */}
<Section style={contentStyle}>{children}</Section>
{/* Footer Rubis — "Rubis sur l'ongle" cliquable vers la landing */}
<Section style={footerStyle}>
<Text style={footerTextStyle}>
Émis via{' '}
{landingUrl ? (
<Link href={landingUrl} style={footerLinkStyle}>
Rubis sur l&apos;ongle
</Link>
) : (
<strong style={{ color: BRAND.ink2 }}>Rubis sur l&apos;ongle</strong>
)}
{' — '}
<span style={{ color: BRAND.ink3 }}>
vos factures relancées toutes seules pendant que vous travaillez.
</span>
</Text>
</Section>
</Container>
</Body>
</Html>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const bodyStyle: React.CSSProperties = {
backgroundColor: BRAND.cream,
fontFamily: BRAND.fontBody,
margin: 0,
padding: `${sp.xl} 0`,
color: BRAND.ink,
}
const containerStyle: React.CSSProperties = {
backgroundColor: BRAND.white,
borderRadius: BRAND.radiusCard,
border: `1px solid ${BRAND.line}`,
margin: '0 auto',
maxWidth: '560px',
overflow: 'hidden',
}
const headerStyle: React.CSSProperties = {
backgroundColor: BRAND.rubisDeep,
padding: `${sp.xl} ${sp.xl}`,
}
const headerBrandStyle: React.CSSProperties = {
color: BRAND.white,
fontSize: '20px',
fontWeight: 800,
letterSpacing: '-0.01em',
margin: 0,
lineHeight: '1.1',
}
const gemStyle: React.CSSProperties = {
display: 'inline-block',
marginRight: sp.sm,
color: BRAND.rubisGlow,
fontSize: '18px',
verticalAlign: '-1px',
}
const headerSubtitleStyle: React.CSSProperties = {
color: BRAND.cream2,
fontSize: '12px',
margin: `${sp.xs} 0 0 0`,
letterSpacing: '0.04em',
textTransform: 'uppercase',
fontWeight: 600,
}
const contentStyle: React.CSSProperties = {
padding: `${sp.xl} ${sp.xl}`,
}
const footerStyle: React.CSSProperties = {
borderTop: `1px solid ${BRAND.line}`,
backgroundColor: BRAND.cream2,
padding: `${sp.lg} ${sp.xl}`,
}
const footerTextStyle: React.CSSProperties = {
color: BRAND.ink3,
fontSize: '11px',
lineHeight: '1.5',
margin: 0,
textAlign: 'center',
}
const footerLinkStyle: React.CSSProperties = {
color: BRAND.rubis,
fontWeight: 600,
textDecoration: 'none',
}

View File

@ -0,0 +1,213 @@
/**
* Template email check-in envoyé à L'UTILISATEUR (le patron de TPE)
* pour lui demander si une facture donnée a é payée AVANT que la 1re
* relance ne parte chez son client.
*
* Structure :
* - Header rubis-deep "Rubis · Confirmation requise"
* - Eyebrow + question Avez-vous é payé ?
* - Card facture (numéro + montant + client + échéance)
* - 2 gros boutons CTA :
* "✓ Oui — la facture est payée" /paid (status=paid + cancel relances)
* "→ Non — toujours impayée, lance les relances" /pending
* - Mention TTL 24h
*/
import * as React from 'react'
import { Section, Text, Button, Heading, Hr } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type CheckinEmailProps = {
invoice: {
numero: string
amountFormatted: string
dueDateFormatted: string
}
client: { name: string }
user: { fullName: string | null }
paidUrl: string
pendingUrl: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
landingUrl?: string
}
export function CheckinEmail({
invoice,
client,
user,
paidUrl,
pendingUrl,
landingUrl,
}: CheckinEmailProps) {
const greeting = user.fullName ? `Bonjour ${user.fullName.split(' ')[0]},` : 'Bonjour,'
return (
<EmailLayout
preview={`${invoice.numero} (${invoice.amountFormatted}) — payée par ${client.name} ?`}
brandName="Rubis"
brandSubtitle="Confirmation requise"
landingUrl={landingUrl}
>
<Text style={greetingStyle}>{greeting}</Text>
<Heading as="h1" style={titleStyle}>
Avez-vous é <em style={emStyle}>payé</em> sur cette facture&nbsp;?
</Heading>
<Text style={leadStyle}>
Aucune relance ne part sans votre validation. Si la facture est déjà
réglée, on évite l&apos;email inutile et on encaisse +1 rubis.
</Text>
{/* Card facture */}
<Section style={invoiceCardStyle}>
<Text style={invoiceNumeroStyle}>{invoice.numero}</Text>
<Text style={invoiceClientStyle}>{client.name}</Text>
<Text style={invoiceAmountStyle}>{invoice.amountFormatted}</Text>
<Text style={invoiceDueStyle}>
Échéance le <strong>{invoice.dueDateFormatted}</strong>
</Text>
</Section>
{/* CTA boutons */}
<Section style={ctaSectionStyle}>
<Button href={paidUrl} style={primaryButtonStyle}>
Oui, la facture est payée
</Button>
</Section>
<Section style={{ marginTop: sp.md }}>
<Button href={pendingUrl} style={secondaryButtonStyle}>
Non, toujours en attente lance les relances
</Button>
</Section>
<Hr style={hrStyle} />
<Text style={footnoteStyle}>
Ces liens expirent dans 24h. Vous pouvez aussi répondre directement
depuis l&apos;app sur la fiche facture.
</Text>
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const greetingStyle: React.CSSProperties = {
fontSize: '14px',
color: BRAND.ink2,
margin: `0 0 ${sp.md} 0`,
}
const titleStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '24px',
fontWeight: 700,
letterSpacing: '-0.018em',
lineHeight: '1.2',
margin: `0 0 ${sp.md} 0`,
}
const emStyle: React.CSSProperties = {
color: BRAND.rubis,
fontStyle: 'normal',
}
const leadStyle: React.CSSProperties = {
color: BRAND.ink2,
fontSize: '14px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
}
const invoiceCardStyle: React.CSSProperties = {
backgroundColor: BRAND.cream,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.lg} ${sp.xl}`,
margin: `0 0 ${sp.xl} 0`,
}
const invoiceNumeroStyle: React.CSSProperties = {
fontSize: '13px',
fontWeight: 600,
color: BRAND.ink2,
letterSpacing: '0.02em',
margin: 0,
}
const invoiceClientStyle: React.CSSProperties = {
fontSize: '13px',
color: BRAND.ink3,
margin: `${sp.xs} 0 ${sp.md} 0`,
}
const invoiceAmountStyle: React.CSSProperties = {
fontSize: '28px',
fontWeight: 800,
letterSpacing: '-0.02em',
color: BRAND.ink,
margin: 0,
lineHeight: '1',
fontVariantNumeric: 'tabular-nums',
}
const invoiceDueStyle: React.CSSProperties = {
fontSize: '12px',
color: BRAND.ink3,
margin: `${sp.sm} 0 0 0`,
}
const ctaSectionStyle: React.CSSProperties = {
marginTop: 0,
}
const primaryButtonStyle: React.CSSProperties = {
backgroundColor: BRAND.rubis,
color: BRAND.white,
borderRadius: BRAND.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
// Shadow rubis-teintée pour cohérence avec les boutons SPA
boxShadow: '0 4px 12px rgba(159, 18, 57, 0.25)',
}
const secondaryButtonStyle: React.CSSProperties = {
backgroundColor: BRAND.white,
color: BRAND.ink,
border: `1px solid ${BRAND.ink}`,
borderRadius: BRAND.radiusButton,
display: 'block',
textAlign: 'center',
textDecoration: 'none',
padding: `${sp.md} ${sp.xl}`,
fontSize: '15px',
fontWeight: 600,
width: '100%',
boxSizing: 'border-box',
}
const hrStyle: React.CSSProperties = {
borderColor: BRAND.line,
borderStyle: 'solid',
borderWidth: '0 0 1px 0',
margin: `${sp.xl} 0 ${sp.lg} 0`,
}
const footnoteStyle: React.CSSProperties = {
fontSize: '11.5px',
color: BRAND.ink3,
lineHeight: '1.5',
margin: 0,
fontStyle: 'italic',
}

View File

@ -0,0 +1,135 @@
/**
* Template email de relance envoyé AU CLIENT FINAL pour réclamer le
* paiement d'une facture impayée. Le subject + le body sont définis par
* le user dans son plan de relance (avec variables {{numero}} etc.) ; on
* injecte ce body brut dans une mise en page Rubis :
*
* - Header rubis-deep avec le NOM DE L'ORG (ce que connaît le client)
* et le numéro de facture en sous-titre
* - Body rendu (le texte que le user a rédigé, déjà interpolé)
* - Card "récap facture" en pied de body : numéro, montant, échéance
*
* Pas de boutons CTA dans la relance le client est censé payer hors
* mail (virement, espèces, ...). Le mail rappelle juste le contexte.
*/
import * as React from 'react'
import { Section, Text } from '@react-email/components'
import { BRAND, sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type RelanceEmailProps = {
/** Nom commercial visible côté client (l'org du user). */
brandName: string
invoice: {
numero: string
amountFormatted: string
dueDateFormatted: string
daysLate: number
}
/** Texte de la relance (déjà interpolé) que le user a posé dans son plan. */
bodyText: string
/** URL landing publique (footer cliquable "Rubis sur l'ongle"). */
landingUrl?: string
}
export function RelanceEmail({
brandName,
invoice,
bodyText,
landingUrl,
}: RelanceEmailProps) {
const isLate = invoice.daysLate > 0
return (
<EmailLayout
preview={`${invoice.numero} (${invoice.amountFormatted}) — ${
isLate ? `${invoice.daysLate}j de retard` : 'à régler'
}`}
brandName={brandName}
brandSubtitle={`Facture ${invoice.numero}`}
landingUrl={landingUrl}
>
{/* Body texte en pre-line pour conserver les sauts de ligne tels que
le user les a écrits. Le client lit le mot du patron, pas un
template impersonnel. */}
<Text style={bodyTextStyle}>{bodyText}</Text>
{/* Card récap en pied : tableau visuel court qui rappelle les chiffres. */}
<Section style={summaryCardStyle}>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Facture</span>
<span style={summaryValueStyle}>{invoice.numero}</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Montant TTC</span>
<span
style={{
...summaryValueStyle,
fontSize: '18px',
fontWeight: 800,
fontVariantNumeric: 'tabular-nums',
}}
>
{invoice.amountFormatted}
</span>
</Text>
<Text style={summaryRowStyle}>
<span style={summaryLabelStyle}>Échéance</span>
<span
style={{
...summaryValueStyle,
color: isLate ? BRAND.rubisDeep : BRAND.ink,
}}
>
{invoice.dueDateFormatted}
{isLate ? (
<span style={{ color: BRAND.rubisDeep, fontSize: '12px', marginLeft: sp.sm }}>
({invoice.daysLate}j de retard)
</span>
) : null}
</span>
</Text>
</Section>
</EmailLayout>
)
}
// ---------------------------------------------------------------------------
// Styles inline
// ---------------------------------------------------------------------------
const bodyTextStyle: React.CSSProperties = {
color: BRAND.ink,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.xl} 0`,
whiteSpace: 'pre-line', // préserve les \n du body sans nécessiter <br/>
}
const summaryCardStyle: React.CSSProperties = {
backgroundColor: BRAND.cream,
border: `1px solid ${BRAND.line}`,
borderRadius: BRAND.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0 0 0`,
}
const summaryRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '13px',
lineHeight: '1.4',
}
const summaryLabelStyle: React.CSSProperties = {
display: 'inline-block',
width: '110px',
color: BRAND.ink3,
fontWeight: 500,
}
const summaryValueStyle: React.CSSProperties = {
color: BRAND.ink,
fontWeight: 600,
}

View File

@ -2,6 +2,7 @@ import mail from '@adonisjs/mail/services/main'
import logger from '@adonisjs/core/services/logger'
import env from '#start/env'
import { DateTime } from 'luxon'
import { render } from '@react-email/components'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import * as clock from '#services/clock'
import { captureEmailIfDemo } from '#services/demo/capture'
@ -11,6 +12,9 @@ import type PlanStep from '#models/plan_step'
import type User from '#models/user'
import type Organization from '#models/organization'
import { CheckinEmail } from '#mails/checkin_email'
import { RelanceEmail } from '#mails/relance_email'
type RelancePayload = {
invoice: Invoice
client: Client
@ -99,13 +103,36 @@ export async function sendRelanceEmail({
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
// Le client final connaît l'org (ex: "Arthur Barré"), pas Rubis. On utilise
// le nom de l'org comme display name visible côté client. Fallback :
// user.fullName, puis MAIL_FROM_NAME (= "Rubis Sur l'Ongle") en dernier
// user.fullName, puis MAIL_FROM_NAME (= "Rubis sur l'ongle") en dernier
// recours si l'org n'a pas de nom posé.
// L'adresse technique reste sur notre domaine vérifié (SPF/DKIM Resend).
const fromName =
organization?.name?.trim() ||
user?.fullName?.trim() ||
env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
// Calcule daysLate pour le récap visuel dans le HTML.
const nowOrg = await clock.now(invoice.organizationId)
const daysLate = Math.floor(
nowOrg.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
const landingUrl = env.get('LANDING_URL', 'https://rubis.arthurbarre.fr')
// Rendu HTML via React Email — DA Rubis (header rubis-deep + card cream).
const htmlBody = await render(
RelanceEmail({
brandName: fromName,
invoice: {
numero: invoice.numero,
amountFormatted: formatAmountFr(invoice.amountTtcCents),
dueDateFormatted: formatDateFr(invoice.dueDate.toJSDate()),
daysLate,
},
bodyText: body,
landingUrl,
})
)
// FORK DÉMO — unique point où l'app dévie de la prod. Si l'org est
// en mode démo, on capture l'email dans demo_captured_emails au lieu
@ -147,8 +174,10 @@ export async function sendRelanceEmail({
m.from(fromAddress, fromName)
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
// décidé d'un look graphique pour les relances.
// HTML rendu depuis le composant React Email (DA Rubis).
.html(htmlBody)
// Plain text fallback : améliore la délivrabilité (anti-spam) et
// sert pour les clients qui désactivent le HTML.
.text(body)
// Reply-To pointe sur l'utilisateur Rubis : si le client final répond
// à la relance, sa réponse arrive chez le patron de la TPE, pas dans
@ -213,7 +242,27 @@ Merci,
L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// Le check-in vient FROM Rubis (notification interne à l'user, pas au
// client final). On garde donc le brand "Rubis sur l'ongle" comme display,
// PAS le nom de l'org.
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
// Rendu HTML — DA Rubis avec 2 boutons CTA Oui/Non.
const landingUrl = env.get('LANDING_URL', 'https://rubis.arthurbarre.fr')
const htmlBody = await render(
CheckinEmail({
invoice: {
numero: invoice.numero,
amountFormatted: formatAmountFr(invoice.amountTtcCents),
dueDateFormatted: formatDateFr(invoice.dueDate.toJSDate()),
},
client: { name: client.name },
user: { fullName: user.fullName ?? null },
paidUrl,
pendingUrl,
landingUrl,
})
)
// FORK DÉMO — capture si demoMode (cf. sendRelanceEmail).
const captured = await captureEmailIfDemo({
@ -233,6 +282,8 @@ L'équipe Rubis`
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
// HTML rendu via React Email (DA Rubis), texte brut en fallback.
.html(htmlBody)
.text(body)
})
}

View File

@ -20,7 +20,7 @@ export function getStripe(): Stripe {
apiVersion: '2026-04-22.dahlia',
typescript: true,
appInfo: {
name: 'Rubis Sur l\'Ongle',
name: 'Rubis sur l\'ongle',
version: '1.0.0',
},
})

View File

@ -1,19 +1,31 @@
import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import mail from '@adonisjs/mail/services/main'
import { render } from '@react-email/components'
import { DateTime } from 'luxon'
import env from '#start/env'
import { CheckinEmail } from '#mails/checkin_email'
import { RelanceEmail } from '#mails/relance_email'
/**
* Envoie un email de test via le mailer courant (typiquement Resend)
* pour valider la conf SPF/DKIM/clé API sans passer par toute la chaîne
* facture job BullMQ.
* pour valider la conf SPF/DKIM/clé API ET le rendu visuel des templates
* HTML React Email sans passer par toute la chaîne facture BullMQ.
*
* node ace send:test-email arthur@example.com
* Usage :
* node ace send:test-email arthur@example.com # checkin
* node ace send:test-email arthur@example.com --template=checkin
* node ace send:test-email arthur@example.com --template=relance
* node ace send:test-email arthur@example.com --template=plain # ancien
* node ace send:test-email arthur@example.com --reply-to=patron@tpe.fr
*
* Default `checkin` car c'est le template avec les boutons CTA le plus
* intéressant à valider visuellement au premier coup d'œil.
*/
export default class SendTestEmail extends BaseCommand {
static commandName = 'send:test-email'
static description = 'Envoie un email de test via le mailer configuré (Resend en prod)'
static description = 'Envoie un email de test (HTML React Email) pour valider rendu + driver'
static options: CommandOptions = {
startApp: true,
@ -22,38 +34,88 @@ export default class SendTestEmail extends BaseCommand {
@args.string({ description: 'Adresse destinataire' })
declare to: string
@flags.string({
description: 'Template à envoyer : checkin (default) | relance | plain',
default: 'checkin',
})
declare template: string
@flags.string({ description: 'Adresse de reply-to (optionnelle)' })
declare replyTo?: string
async run() {
const driver = env.get('MAIL_DRIVER', 'smtp')
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const landingUrl = env.get('LANDING_URL', 'https://rubis.arthurbarre.fr')
this.logger.info(`Driver: ${driver}`)
this.logger.info(`From: ${fromName} <${fromAddress}>`)
this.logger.info(`To: ${this.to}`)
if (this.replyTo) this.logger.info(`ReplyTo: ${this.replyTo}`)
this.logger.info(`Driver: ${driver}`)
this.logger.info(`From: ${fromName} <${fromAddress}>`)
this.logger.info(`To: ${this.to}`)
this.logger.info(`Template: ${this.template}`)
if (this.replyTo) this.logger.info(`ReplyTo: ${this.replyTo}`)
// Données factices pour le rendu — évite d'avoir à créer une vraie
// facture en DB juste pour tester l'email.
const fakeInvoice = {
numero: 'F2026-TEST',
amountFormatted: '1 234,56 €',
dueDateFormatted: DateTime.utc().toFormat('dd/LL/yyyy'),
}
let subject: string
let text: string
let html: string | undefined
if (this.template === 'plain') {
subject = "[Rubis] Test d'envoi (plain text)"
text =
`Bonjour,\n\n` +
`Ceci est un email de test envoyé depuis Rubis sur l'ongle.\n` +
`Driver : ${driver}\n` +
`Date : ${new Date().toISOString()}\n\n` +
`— L'équipe Rubis`
html = undefined
} else if (this.template === 'relance') {
subject = `Facture ${fakeInvoice.numero} échue (test)`
text =
`Bonjour,\n\nPetit rappel pour la facture ${fakeInvoice.numero}.\n\nCordialement,\nArthur Barré`
html = await render(
RelanceEmail({
brandName: 'Arthur Barré (test)',
invoice: { ...fakeInvoice, daysLate: 3 },
bodyText: text,
landingUrl,
})
)
} else {
// checkin par défaut
subject = `[Rubis] Test — Facture ${fakeInvoice.numero}, payée ?`
text =
`Test d'envoi du template check-in.\n\n` +
`Lien "payée" : https://example.com/paid\n` +
`Lien "pending" : https://example.com/pending\n\n` +
`— L'équipe Rubis`
html = await render(
CheckinEmail({
invoice: fakeInvoice,
client: { name: 'Boulangerie Test' },
user: { fullName: 'Arthur Barré' },
paidUrl: 'https://example.com/paid',
pendingUrl: 'https://example.com/pending',
landingUrl,
})
)
}
const mailer = mail.use(driver)
const response = await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(this.to)
.subject('[Rubis] Test d\'envoi via Resend')
.text(
`Bonjour,\n\n` +
`Ceci est un email de test envoyé depuis Rubis Sur l'Ongle.\n` +
`Si vous recevez ce message, la conf Resend (SPF/DKIM/API key) est OK.\n\n` +
`Driver utilisé : ${driver}\n` +
`Date : ${new Date().toISOString()}\n\n` +
`— L'équipe Rubis`
)
m.from(fromAddress, fromName).to(this.to).subject(subject).text(text)
if (html) m.html(html)
if (this.replyTo) m.replyTo(this.replyTo)
})
this.logger.success('Email envoyé')
// Resend renvoie un messageId dans la réponse — utile pour retrouver
// le log dans le dashboard.
if (response?.messageId) {
this.logger.info(`messageId: ${response.messageId}`)
}

View File

@ -7,14 +7,14 @@ const mailConfig = defineConfig({
from: {
address: env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'),
name: env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"),
name: env.get('MAIL_FROM_NAME', "Rubis sur l'ongle"),
},
/**
* Variables partagées par tous les templates Edge (logo, URL de base).
*/
globals: {
brandName: "Rubis Sur l'Ongle",
brandName: "Rubis sur l'ongle",
appUrl: env.get('APP_URL'),
},

View File

@ -465,7 +465,7 @@ function Footer({ iban }: { iban: string }) {
Règlement par virement IBAN {iban} · Pénalités de retard : 3× taux légal
· Indemnité forfaitaire 40 (art. L.441-10 C. com.)
</Text>
<Text style={styles.footerBrand}>Émise via Rubis Sur l'Ongle</Text>
<Text style={styles.footerBrand}>Émise via Rubis sur l'ongle</Text>
</View>
)
}

View File

@ -75,6 +75,8 @@
"@aws-sdk/client-s3": "^3.1043.0",
"@aws-sdk/s3-request-presigner": "^3.1043.0",
"@japa/api-client": "^3.2.1",
"@react-email/components": "^1.0.12",
"@react-email/render": "^2.0.8",
"@react-pdf/renderer": "^4.5.1",
"@tuyau/core": "^1.2.2",
"@vinejs/vine": "^4.3.1",

View File

@ -68,6 +68,9 @@ export default await Env.create(new URL('../', import.meta.url), {
// Web (URL du SPA pour redirects post-checkin)
WEB_URL: Env.schema.string.optional({ format: 'url', tld: false }),
// Landing public (lien dans le footer des emails — branding)
LANDING_URL: Env.schema.string.optional({ format: 'url', tld: false }),
// Auth
ACCESS_TOKEN_TTL_MINUTES: Env.schema.number.optional(),
REFRESH_TOKEN_TTL_DAYS: Env.schema.number.optional(),

428
pnpm-lock.yaml generated
View File

@ -77,6 +77,12 @@ importers:
'@japa/api-client':
specifier: ^3.2.1
version: 3.2.1(@japa/assert@4.2.0(@japa/runner@5.3.0))(@japa/runner@5.3.0)
'@react-email/components':
specifier: ^1.0.12
version: 1.0.12(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@react-email/render':
specifier: ^2.0.8
version: 2.0.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@react-pdf/renderer':
specifier: ^4.5.1
version: 4.5.1(react@19.2.5)
@ -1953,6 +1959,192 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-email/body@0.3.0':
resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/button@0.2.1':
resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/code-block@0.2.1':
resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/code-inline@0.0.6':
resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/column@0.0.14':
resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/components@1.0.12':
resolution: {integrity: sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/container@0.0.16':
resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/font@0.0.10':
resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/head@0.0.13':
resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/heading@0.0.16':
resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/hr@0.0.12':
resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/html@0.0.12':
resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/img@0.0.12':
resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/link@0.0.13':
resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/markdown@0.0.18':
resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/preview@0.0.14':
resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/render@2.0.6':
resolution: {integrity: sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/render@2.0.8':
resolution: {integrity: sha512-5udvVr3U/WuGJZfLdLBOhkzrqRWd2Q5ZYmF7ppcy7FzWcwgshdqLMNqJOXcVzAXJXg/2bm7D+WGJzTtZOZMQnQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/row@0.0.13':
resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/section@0.0.17':
resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/tailwind@2.0.7':
resolution: {integrity: sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
'@react-email/body': '>=0'
'@react-email/button': '>=0'
'@react-email/code-block': '>=0'
'@react-email/code-inline': '>=0'
'@react-email/container': '>=0'
'@react-email/heading': '>=0'
'@react-email/hr': '>=0'
'@react-email/img': '>=0'
'@react-email/link': '>=0'
'@react-email/preview': '>=0'
'@react-email/text': '>=0'
react: ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@react-email/body':
optional: true
'@react-email/button':
optional: true
'@react-email/code-block':
optional: true
'@react-email/code-inline':
optional: true
'@react-email/container':
optional: true
'@react-email/heading':
optional: true
'@react-email/hr':
optional: true
'@react-email/img':
optional: true
'@react-email/link':
optional: true
'@react-email/preview':
optional: true
'@react-email/text@0.1.6':
resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==}
engines: {node: '>=20.0.0'}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-pdf/fns@3.1.3':
resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==}
@ -2306,6 +2498,9 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@sinclair/typebox@0.34.49':
resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==}
@ -3701,6 +3896,19 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@ -3742,6 +3950,10 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@ -4212,6 +4424,13 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@ -4577,6 +4796,9 @@ packages:
resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==}
engines: {node: '>=18'}
leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@ -4727,6 +4949,11 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@ -4965,6 +5192,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -5001,6 +5231,9 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
@ -5144,6 +5377,10 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
@ -5417,6 +5654,9 @@ packages:
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@ -8026,6 +8266,137 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@react-email/body@0.3.0(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/button@0.2.1(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/code-block@0.2.1(react@19.2.5)':
dependencies:
prismjs: 1.30.0
react: 19.2.5
'@react-email/code-inline@0.0.6(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/column@0.0.14(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/components@1.0.12(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@react-email/body': 0.3.0(react@19.2.5)
'@react-email/button': 0.2.1(react@19.2.5)
'@react-email/code-block': 0.2.1(react@19.2.5)
'@react-email/code-inline': 0.0.6(react@19.2.5)
'@react-email/column': 0.0.14(react@19.2.5)
'@react-email/container': 0.0.16(react@19.2.5)
'@react-email/font': 0.0.10(react@19.2.5)
'@react-email/head': 0.0.13(react@19.2.5)
'@react-email/heading': 0.0.16(react@19.2.5)
'@react-email/hr': 0.0.12(react@19.2.5)
'@react-email/html': 0.0.12(react@19.2.5)
'@react-email/img': 0.0.12(react@19.2.5)
'@react-email/link': 0.0.13(react@19.2.5)
'@react-email/markdown': 0.0.18(react@19.2.5)
'@react-email/preview': 0.0.14(react@19.2.5)
'@react-email/render': 2.0.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@react-email/row': 0.0.13(react@19.2.5)
'@react-email/section': 0.0.17(react@19.2.5)
'@react-email/tailwind': 2.0.7(@react-email/body@0.3.0(react@19.2.5))(@react-email/button@0.2.1(react@19.2.5))(@react-email/code-block@0.2.1(react@19.2.5))(@react-email/code-inline@0.0.6(react@19.2.5))(@react-email/container@0.0.16(react@19.2.5))(@react-email/heading@0.0.16(react@19.2.5))(@react-email/hr@0.0.12(react@19.2.5))(@react-email/img@0.0.12(react@19.2.5))(@react-email/link@0.0.13(react@19.2.5))(@react-email/preview@0.0.14(react@19.2.5))(@react-email/text@0.1.6(react@19.2.5))(react@19.2.5)
'@react-email/text': 0.1.6(react@19.2.5)
react: 19.2.5
transitivePeerDependencies:
- react-dom
'@react-email/container@0.0.16(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/font@0.0.10(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/head@0.0.13(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/heading@0.0.16(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/hr@0.0.12(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/html@0.0.12(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/img@0.0.12(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/link@0.0.13(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/markdown@0.0.18(react@19.2.5)':
dependencies:
marked: 15.0.12
react: 19.2.5
'@react-email/preview@0.0.14(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/render@2.0.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
html-to-text: 9.0.5
prettier: 3.8.3
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@react-email/render@2.0.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
html-to-text: 9.0.5
prettier: 3.8.3
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@react-email/row@0.0.13(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/section@0.0.17(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-email/tailwind@2.0.7(@react-email/body@0.3.0(react@19.2.5))(@react-email/button@0.2.1(react@19.2.5))(@react-email/code-block@0.2.1(react@19.2.5))(@react-email/code-inline@0.0.6(react@19.2.5))(@react-email/container@0.0.16(react@19.2.5))(@react-email/heading@0.0.16(react@19.2.5))(@react-email/hr@0.0.12(react@19.2.5))(@react-email/img@0.0.12(react@19.2.5))(@react-email/link@0.0.13(react@19.2.5))(@react-email/preview@0.0.14(react@19.2.5))(@react-email/text@0.1.6(react@19.2.5))(react@19.2.5)':
dependencies:
'@react-email/text': 0.1.6(react@19.2.5)
react: 19.2.5
tailwindcss: 4.2.4
optionalDependencies:
'@react-email/body': 0.3.0(react@19.2.5)
'@react-email/button': 0.2.1(react@19.2.5)
'@react-email/code-block': 0.2.1(react@19.2.5)
'@react-email/code-inline': 0.0.6(react@19.2.5)
'@react-email/container': 0.0.16(react@19.2.5)
'@react-email/heading': 0.0.16(react@19.2.5)
'@react-email/hr': 0.0.12(react@19.2.5)
'@react-email/img': 0.0.12(react@19.2.5)
'@react-email/link': 0.0.13(react@19.2.5)
'@react-email/preview': 0.0.14(react@19.2.5)
'@react-email/text@0.1.6(react@19.2.5)':
dependencies:
react: 19.2.5
'@react-pdf/fns@3.1.3': {}
'@react-pdf/font@4.0.8':
@ -8323,6 +8694,11 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
domhandler: 5.0.3
selderee: 0.11.0
'@sinclair/typebox@0.34.49': {}
'@sindresorhus/is@7.2.0': {}
@ -9757,6 +10133,24 @@ snapshots:
dom-accessibility-api@0.6.3: {}
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@ -9795,6 +10189,8 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@4.5.0: {}
entities@6.0.1: {}
environment@1.1.0: {}
@ -10332,6 +10728,21 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
deepmerge: 4.3.1
dom-serializer: 2.0.0
htmlparser2: 8.0.2
selderee: 0.11.0
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@ -10621,6 +11032,8 @@ snapshots:
ky@1.14.3: {}
leac@0.6.0: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@ -10762,6 +11175,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@15.0.12: {}
math-intrinsics@1.1.0: {}
media-engine@1.0.3: {}
@ -10987,6 +11402,11 @@ snapshots:
dependencies:
entities: 6.0.1
parseley@0.12.1:
dependencies:
leac: 0.6.0
peberminta: 0.9.0
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
@ -11007,6 +11427,8 @@ snapshots:
pathval@2.0.1: {}
peberminta@0.9.0: {}
pg-cloudflare@1.3.0:
optional: true
@ -11166,6 +11588,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prismjs@1.30.0: {}
process-warning@5.0.0: {}
prop-types@15.8.1:
@ -11458,6 +11882,10 @@ snapshots:
secure-json-parse@4.1.0: {}
selderee@0.11.0:
dependencies:
parseley: 0.12.1
semver@6.3.1: {}
semver@7.7.4: {}