Courriel
Envoi via Resend ou SMTP et gabarit React Email BaseLayout, le seul point d'envoi de courriel de la plateforme.
core/email envoie les courriels par Resend ou par un relais SMTP, et fournit un gabarit React Email réutilisable. Il ne contient aucun gabarit métier.
Règle absolue : tout envoi de courriel passe par ce module.
Import
import { sendEmail, sendBatchEmails } from '@lrvo/core/email';
import { BaseLayout } from '@lrvo/core/email/templates';
// Chargée par `await import()`, jamais au top-level. Voir les notes.
const { render, Text, Section, Button } = await import('@lrvo/core/email/components');Module serveur.
@lrvo/core/email/components
Réexporte @react-email/components : render, Html, Head, Preview, Tailwind, Body, Container, Section, Row, Column, Heading, Text, Button, Link, Hr, Img.
Un module compose donc ses gabarits sans déclarer react-email dans son package.json, exactement comme core/pdf lui évite de dépendre de @react-pdf/renderer. Une seule version de react-email circule dans le projet, celle du core.
Cette sous-entrée se charge paresseusement, pour la raison expliquée dans les notes.
Fournisseurs
Deux fournisseurs, choisis par LRVO_EMAIL_PROVIDER : resend par défaut, smtp pour un relais existant. L'API publique est la même dans les deux cas, c'est le point de tout l'aiguillage : passer de l'un à l'autre est un changement de .env, jamais un changement de code.
resend | smtp | |
|---|---|---|
| Transport | API HTTP | protocole SMTP, écrit dans le core |
| Envoi en lot | une requête batch | une connexion réutilisée, un message à la fois |
attachments | content ou path | content seulement |
tags | transmises | ignorées |
Forme de data | { id } | { id, response }, et un tableau pour le lot |
Une valeur inconnue de LRVO_EMAIL_PROVIDER fait échouer l'envoi avec un message qui nomme la variable, plutôt que de retomber sur le défaut. Une faute de frappe qui route vers Resend se manifesterait par « clé API absente », soit un diagnostic à trois pas de la cause.
Le fournisseur SMTP est chargé par await import(), jamais en statique : il importe node:net et node:tls, alors que @lrvo/core/email doit rester un module neutre. Un projet sous Resend ne charge donc jamais le code SMTP, et réciproquement un projet sous SMTP ne charge jamais le SDK Resend.
Variables d'environnement
Les variables communes servent les deux fournisseurs. Seules les variables du fournisseur actif sont lues.
| Variable | Obligatoire | Rôle |
|---|---|---|
LRVO_EMAIL_PROVIDER | non | resend (défaut) ou smtp. |
LRVO_EMAIL_FROM_ADDRESS | oui | Adresse expéditeur par défaut. |
LRVO_EMAIL_FROM_NAME | non | Nom affiché de l'expéditeur. |
LRVO_EMAIL_LOGO | non | URL du logo affiché dans BaseLayout. |
LRVO_EMAIL_LOGO_URL | non | Destination du lien autour du logo. |
LRVO_SUPPORT_EMAIL | non | Adresse affichée dans le pied quand supportSection est actif. |
LRVO_NAME | non | Nom de l'application, repli du nom affiché dans BaseLayout. |
Propres au fournisseur resend :
| Variable | Obligatoire | Rôle |
|---|---|---|
LRVO_EMAIL_RESEND_APIKEY | oui | Clé API Resend. |
Propres au fournisseur smtp :
| Variable | Défaut | Rôle |
|---|---|---|
LRVO_EMAIL_SMTP_HOST | — | Hôte du relais. Obligatoire. |
LRVO_EMAIL_SMTP_PORT | 587 | Port du relais. |
LRVO_EMAIL_SMTP_USER | — | Identifiant. Vide pour un relais sans authentification. |
LRVO_EMAIL_SMTP_PASSWORD | — | Mot de passe. Vide pour un relais sans authentification. |
LRVO_EMAIL_SMTP_SECURE | déduit du port | true : TLS implicite dès la connexion. Sans valeur : true sur le port 465, false ailleurs. |
LRVO_EMAIL_SMTP_TLS_NO_VERIFY | false | true : TLS actif, certificat non vérifié. Pour un réseau privé ou un certificat auto-signé. |
API
sendEmail(email)
Envoie un courriel. Retourne { success, data, error } et ne lève jamais.
const result = await sendEmail({
to: '[email protected]',
subject: 'Bienvenue',
html: '<p>Bonjour</p>',
});
if (!result.success) {
// l'erreur est déjà journalisée côté serveur
}| Paramètre | Type | Rôle |
|---|---|---|
to | string | string[] | Destinataires. |
subject | string | Objet. |
html | string | Corps HTML. |
text | string | Corps texte brut. Optionnel. |
cc | string | string[] | Copie. Optionnel. |
bcc | string | string[] | Copie cachée. Optionnel. |
from | string | Adresse expéditeur. Défaut : LRVO_EMAIL_FROM_ADDRESS. |
fromName | string | Nom expéditeur. Défaut : LRVO_EMAIL_FROM_NAME. |
replyTo | string | Adresse de réponse. Optionnel. |
attachments | object[] | Pièces jointes. Optionnel. En SMTP, content est obligatoire et path lève. |
tags | object[] | Étiquettes Resend. Optionnel, ignorées en SMTP. |
Le contenu de data dépend du fournisseur : Resend retourne son { id }, SMTP retourne { id, response } où id est le Message-ID généré et response la réponse finale du relais, qui contient souvent son identifiant de file. Seuls success et error sont garantis identiques d'un fournisseur à l'autre.
sendBatchEmails(emails)
Envoie plusieurs courriels. Chaque entrée accepte les mêmes champs que sendEmail.
await sendBatchEmails([
{ to: '[email protected]', subject: 'Sujet A', html: '<p>A</p>' },
{ to: '[email protected]', subject: 'Sujet B', html: '<p>B</p>' },
]);Sous Resend, c'est une seule requête batch. Sous SMTP, il n'existe pas de verbe équivalent : les messages partent l'un après l'autre sur une connexion unique, ce qui économise la poignée de main et l'authentification, pas les allers-retours. Un message refusé par le relais ne condamne pas les suivants, et data est alors un tableau qui indique lesquels sont passés.
BaseLayout
Composant React Email qui fournit une structure cohérente : logo ou nom de l'application, titre optionnel, contenu, pied avec copyright et lien de support.
import { BaseLayout } from '@lrvo/core/email/templates';
const { render, Text } = await import('@lrvo/core/email/components');
const element = (
<BaseLayout preview="Votre commande est confirmée" title="Commande confirmée" supportSection>
<Text>Merci pour votre achat.</Text>
</BaseLayout>
);
const html = await render(element);
const text = await render(element, { plainText: true });
await sendEmail({ to: '[email protected]', subject: 'Commande confirmée', html, text });render(element, { plainText: true }) produit l'alternative texte à partir du même arbre. Un client qui refuse le HTML lit alors le vrai contenu, et non une version dérivée à part qui finit par diverger.
| Prop | Type | Rôle |
|---|---|---|
preview | string | Texte de prévisualisation affiché par le client de messagerie. |
title | string | Titre en haut du corps. |
children | ReactNode | Contenu. |
companyName | string | Nom affiché sans logo. Défaut : LRVO_NAME, sinon LRVO. |
logoURL | string | URL du logo. Défaut : LRVO_EMAIL_LOGO. |
supportSection | boolean | Affiche le lien de support dans le pied. Défaut : false. |
supportEmail | string | Adresse de support. Défaut : LRVO_SUPPORT_EMAIL. |
Couleurs du thème
Les courriels suivent le thème du projet, dans ses valeurs du mode clair : un courriel s'ouvre sur fond blanc quel que soit le thème du lecteur. Les clients de messagerie ignorent les variables CSS, donc les couleurs arrivent en hex.
BaseLayout passe au composant <Tailwind> une configuration tirée du thème. Un gabarit écrit donc les mêmes jetons que l'interface, et react-email les inline :
<Button href={url} className="bg-primary text-primary-fg rounded-control px-[20px] py-[11px]">
Confirmer
</Button>
<Text className="text-fg-muted">Ce lien expire dans une heure.</Text>Deux fonctions couvrent ce que Tailwind n'inline pas :
| Fonction | Retour | Usage |
|---|---|---|
getEmailColors() | { fg, 'fg-muted', line, primary, danger, 'danger-soft', … } | Style inline (border), chaîne HTML écrite à la main. |
getEmailTailwindConfig() | configuration Tailwind | Un gabarit qui pose son propre <Tailwind> au lieu de passer par BaseLayout. |
import { BaseLayout, getEmailColors } from '@lrvo/core/email/templates';
function DetailBox({ children }) {
return <Section style={{ border: `1px solid ${getEmailColors().line}` }}>{children}</Section>;
}Les deux s'appellent au rendu du courriel, jamais au niveau racine d'un fichier : le thème est enregistré après le chargement des modules.
Notes
Le SDK Resend est épinglé en v6. Il n'embarque plus @react-email/render, ce qui supprime la chaîne de dépendances vulnérable js-beautify → minimatch → brace-expansion ; le rendu passe par la dépendance directe @react-email/components. La v6 exige Node 20 ou plus et nomme ses champs en camelCase (replyTo, scheduledAt) : le snake_case de la v3 n'est plus reconnu. Le SDK reste livré même sous SMTP, donc le plancher Node ne change pas avec le fournisseur.
Le client SMTP est écrit dans le core, sans bibliothèque. Même arbitrage que core/storage, qui signe ses requêtes S3 lui-même plutôt que d'embarquer le SDK : une dépendance de moins à suivre à chaque alerte de sécurité. Le protocole vit dans src/core/email/smtp.server.js, la construction du message dans src/core/email/mime.js.
L'authentification SMTP est refusée en clair. Si un identifiant et un mot de passe sont configurés et que la connexion n'est ni en TLS implicite ni passée par STARTTLS, l'envoi échoue au lieu d'envoyer le secret sur le réseau. Aucune variable ne permet de contourner ce refus : les relais qui exigent du clair, comme un Mailpit local ou un Postfix sur réseau privé, n'exigent pas d'authentification.
Les corps sont encodés en base64. Cela supprime d'un coup la limite de longueur de ligne du RFC 5322 et les coupures douces du quoted-printable. Les objets et les noms de fichiers accentués sont encodés en mots RFC 2047, les noms de pièces jointes en RFC 2231.
Les valeurs qui atteignent un en-tête sont nettoyées. Un retour chariot dans un objet ou un destinataire forgerait des en-têtes supplémentaires, un Bcc invisible ou un relais ouvert. Les caractères de contrôle sont retirés, et chaque adresse est validée avant d'entrer dans l'enveloppe. Cela vaut pour toute donnée venue d'un visiteur, par exemple le destinataire d'un formulaire de contact.
Aucune exception n'est levée. Toute erreur est capturée, journalisée via fail() et retournée dans { success: false, error }. L'appelant vérifie result.success : une promesse ignorée ici est un envoi perdu silencieusement.
Les gabarits vivent avec leur feature, pas dans ce module. Les gabarits réutilisables à l'échelle de l'application vont dans src/core/email/templates ; ceux propres à un module restent dans le module. Voir Envoyer un courriel.
Le rendu se charge à la demande, jamais à l'import. @react-email/components embarque le compilateur Tailwind de react-email, qui lit globalThis.localStorage à son évaluation : sur Node 24 et plus, cela déclenche un ExperimentalWarning si le module est chargé au boot. Un fichier importé par la chaîne d'initialisation, comme features/auth/email.js, doit donc importer le moteur de rendu et ses gabarits par await import() à l'intérieur de la fonction d'envoi, avec la promesse mémoïsée pour ne payer le chargement qu'une fois. Un site qui n'envoie aucun courriel ne charge alors jamais ce graphe.
La règle s'étend aux gabarits : un fichier de gabarit importe @lrvo/core/email/components en statique, donc il doit lui-même être atteint par await import(). D'où le regroupement des gabarits derrière un index.js, seule cible de l'import paresseux.
let kitPromise = null;
function loadEmailKit() {
kitPromise ??= (async () => {
const [components, templates] = await Promise.all([
import('@lrvo/core/email/components'),
import('./templates/index.js'),
]);
return { ...components, ...templates };
})();
return kitPromise;
}Mémoïser la promesse et non son résultat : deux envois concurrents partagent alors le même import au lieu d'en déclencher deux.