Configurer le serveur d'envoi de courrier
Sans serveur SMTP, PlanFlow ne peut ni inviter un salarié, ni notifier une publication de planning, ni délivrer l'information due au retour d'un arrêt. L'application n'expédie rien par elle-même : elle se connecte au serveur du client, pour que les messages partent de son domaine et que les salariés reconnaissent l'expéditeur. Le mot de passe SMTP est traité comme le NIR et l'IBAN — chiffré au repos avec la même clé, jamais renvoyé à l'écran (la page ne le charge même pas chiffré), jamais recopié dans un message d'erreur ni dans le journal d'audit. Les erreurs SMTP passent par une passe de masquage : les serveurs renvoient volontiers la commande AUTH en clair. Laisser le champ vide conserve le mot de passe enregistré, sans quoi changer un numéro de port casserait l'envoi. Enregistrer et éprouver sont deux gestes distincts : toute modification remet le réglage en « non vérifié », car un réglage non éprouvé n'est pas un réglage, c'est une intention. L'envoi de test vérifie d'abord la connexion — ce qui sépare une adresse de serveur fautive d'un mot de passe faux — puis expédie réellement. Les messages sont rendus en texte et en HTML depuis le même contenu, sans image, script ni ressource distante : la charte de télémétrie vaut aussi pour le courrier, un pixel de suivi dans un message RH est une collecte que personne n'a acceptée. Le journal d'envoi retient le destinataire et l'issue, pas le corps du message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
12 files changed
+1362
No files matched your search
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
EmailSettingsForm,
|
||||
TestEmailForm,
|
||||
} from '@/components/settings/EmailSettingsForm';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { query, requireSession } from '@/server/context';
|
||||
|
||||
export const metadata = { title: 'Envoi de courrier · PlanFlow' };
|
||||
|
||||
/**
|
||||
* Réglages d'envoi.
|
||||
*
|
||||
* Le mot de passe n'est **pas** chargé, même chiffré : la page n'en a pas
|
||||
* besoin, et une valeur qui ne quitte jamais le serveur ne peut pas fuiter par
|
||||
* le HTML.
|
||||
*/
|
||||
async function loadSettings() {
|
||||
return query('settings.notifications.manage', async (db) => {
|
||||
const settings = await db.emailSettings.findFirst({
|
||||
select: {
|
||||
host: true,
|
||||
port: true,
|
||||
secure: true,
|
||||
username: true,
|
||||
passwordEnc: true,
|
||||
fromName: true,
|
||||
fromAddress: true,
|
||||
replyTo: true,
|
||||
verifiedAt: true,
|
||||
lastError: true,
|
||||
},
|
||||
});
|
||||
if (!settings) return null;
|
||||
|
||||
const { passwordEnc, ...rest } = settings;
|
||||
return { ...rest, hasPassword: passwordEnc !== null };
|
||||
});
|
||||
}
|
||||
|
||||
async function loadRecentLogs() {
|
||||
return query('settings.notifications.manage', async (db) =>
|
||||
db.emailLog.findMany({
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
to: true,
|
||||
subject: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
error: true,
|
||||
sentAt: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export default async function EmailSettingsPage() {
|
||||
const session = await requireSession();
|
||||
const settings = await loadSettings();
|
||||
const logs = await loadRecentLogs();
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Envoi de courrier"
|
||||
subtitle="Le serveur qui expédie les invitations, les notifications et les informations dues aux salariés."
|
||||
actions={
|
||||
settings?.verifiedAt ? (
|
||||
<Badge tone="ok">
|
||||
Vérifié le {settings.verifiedAt.toISOString().slice(0, 10)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="warn">Non vérifié</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{settings?.lastError ? (
|
||||
<p role="alert" className="rounded-3 border border-danger bg-danger-soft p-3 text-sm text-danger-soft-ink">
|
||||
Dernière tentative en échec — {settings.lastError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/*
|
||||
La phrase « c'est *votre* serveur qui envoie » reste affichée une fois
|
||||
le réglage posé : elle répond à « depuis quelle adresse partent les
|
||||
messages ? », question qui se pose surtout quand la configuration
|
||||
existe déjà et qu'un salarié demande d'où vient le courrier.
|
||||
*/}
|
||||
<section className="rounded-3 border border-line-1 bg-surface-2 p-4 text-sm text-ink-2">
|
||||
{!settings ? (
|
||||
<p className="mb-2 text-ink-1">
|
||||
<strong>Aucun serveur d’envoi n’est configuré.</strong> Tant que ce
|
||||
réglage manque, PlanFlow ne peut ni inviter un salarié, ni notifier
|
||||
une publication de planning, ni délivrer l’information due au retour
|
||||
d’un arrêt.
|
||||
</p>
|
||||
) : null}
|
||||
<p>
|
||||
PlanFlow n’envoie rien par lui-même : il se connecte à{' '}
|
||||
<strong>votre</strong> serveur de messagerie. Les messages partent
|
||||
donc de votre domaine, et vos salariés reconnaissent l’expéditeur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<EmailSettingsForm settings={settings} />
|
||||
|
||||
{settings ? <TestEmailForm defaultTo={session.user.email} /> : null}
|
||||
|
||||
<section className="rounded-3 border border-line-1 bg-surface">
|
||||
<h2 className="border-b border-line-1 px-4 py-2 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Derniers envois
|
||||
</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">Aucun envoi enregistré.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-1">
|
||||
{logs.map((log) => (
|
||||
<li
|
||||
key={log.id}
|
||||
className="flex flex-wrap items-center gap-3 px-4 py-2 text-sm"
|
||||
>
|
||||
<Badge tone={log.status === 'SENT' ? 'ok' : 'danger'}>
|
||||
{log.status === 'SENT' ? 'Envoyé' : 'Échec'}
|
||||
</Badge>
|
||||
<span className="text-ink-1">{log.to}</span>
|
||||
<span className="text-ink-2">{log.subject}</span>
|
||||
{log.error ? (
|
||||
<span className="text-micro text-danger">{log.error}</span>
|
||||
) : null}
|
||||
<span className="ml-auto text-micro text-ink-3">
|
||||
{log.sentAt.toISOString().slice(0, 16).replace('T', ' ')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="border-t border-line-1 px-4 py-2 text-micro text-ink-3">
|
||||
Le contenu des messages n’est pas conservé : il porte des données
|
||||
personnelles et se régénère. Le journal sert à répondre à « je n’ai
|
||||
rien reçu », pas à relire le courrier.
|
||||
</p>
|
||||
</section>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
saveEmailSettingsAction,
|
||||
sendTestEmailAction,
|
||||
type EmailActionState,
|
||||
} from '@/server/email/actions';
|
||||
|
||||
const empty: EmailActionState = {};
|
||||
|
||||
export interface EmailSettingsView {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string | null;
|
||||
hasPassword: boolean;
|
||||
fromName: string;
|
||||
fromAddress: string;
|
||||
replyTo: string | null;
|
||||
verifiedAt: Date | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réglages du serveur d'envoi.
|
||||
*
|
||||
* Le mot de passe n'est **jamais pré-rempli** : le serveur ne l'envoie pas à
|
||||
* l'écran, même masqué. Le laisser vide conserve celui déjà enregistré — sans
|
||||
* quoi changer un numéro de port casserait l'envoi.
|
||||
*/
|
||||
export function EmailSettingsForm({
|
||||
settings,
|
||||
}: {
|
||||
settings: EmailSettingsView | null;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
saveEmailSettingsAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="grid gap-4 rounded-3 border border-line-1 bg-surface p-4 sm:grid-cols-2"
|
||||
>
|
||||
<Field
|
||||
label="Serveur SMTP"
|
||||
name="host"
|
||||
defaultValue={settings?.host ?? ''}
|
||||
placeholder="smtp.example.fr"
|
||||
required
|
||||
hint="Fourni par votre hébergeur de messagerie."
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Port"
|
||||
name="port"
|
||||
type="number"
|
||||
defaultValue={String(settings?.port ?? 587)}
|
||||
required
|
||||
hint="587 avec STARTTLS, 465 avec TLS implicite."
|
||||
/>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-ink-2 sm:col-span-2">
|
||||
<input
|
||||
name="secure"
|
||||
type="checkbox"
|
||||
defaultChecked={settings?.secure ?? false}
|
||||
className="mt-0.5 size-4"
|
||||
/>
|
||||
<span>
|
||||
TLS implicite (port 465)
|
||||
<span className="block text-micro text-ink-3">
|
||||
À cocher pour le port 465 seulement. Sur 587, laissez décoché : la
|
||||
connexion est chiffrée par STARTTLS.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Field
|
||||
label="Identifiant"
|
||||
name="username"
|
||||
defaultValue={settings?.username ?? ''}
|
||||
placeholder="souvent l’adresse complète"
|
||||
hint="Laissez vide si le serveur n’exige pas d’authentification."
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Mot de passe"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
settings?.hasPassword ? '•••••••• (inchangé)' : 'Mot de passe SMTP'
|
||||
}
|
||||
hint={
|
||||
settings?.hasPassword
|
||||
? 'Laissez vide pour conserver celui enregistré.'
|
||||
: 'Chiffré au repos, jamais réaffiché.'
|
||||
}
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Nom d’expéditeur"
|
||||
name="fromName"
|
||||
defaultValue={settings?.fromName ?? ''}
|
||||
placeholder="Maison Rivage — RH"
|
||||
required
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Adresse d’expéditeur"
|
||||
name="fromAddress"
|
||||
type="email"
|
||||
defaultValue={settings?.fromAddress ?? ''}
|
||||
placeholder="rh@example.fr"
|
||||
required
|
||||
hint="Doit appartenir à un domaine que votre serveur est autorisé à signer (SPF, DKIM), sinon les messages partent en indésirable."
|
||||
/>
|
||||
|
||||
<Field
|
||||
label="Répondre à"
|
||||
name="replyTo"
|
||||
type="email"
|
||||
defaultValue={settings?.replyTo ?? ''}
|
||||
placeholder="Facultatif"
|
||||
hint="Adresse où arrivent les réponses des salariés."
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<Button type="submit" variant="primary" disabled={pending}>
|
||||
Enregistrer
|
||||
</Button>
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
{state.message ? (
|
||||
<span className="text-xs text-ok-soft-ink">{state.message}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoi de test.
|
||||
*
|
||||
* Séparé de l'enregistrement : un réglage non éprouvé n'est pas un réglage,
|
||||
* c'est une intention. Tant que le test n'a pas abouti, l'écran le dit.
|
||||
*/
|
||||
export function TestEmailForm({ defaultTo }: { defaultTo: string }) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
sendTestEmailAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Envoyer un test à
|
||||
</span>
|
||||
<input
|
||||
name="to"
|
||||
type="email"
|
||||
defaultValue={defaultTo}
|
||||
required
|
||||
className="h-8 w-64 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={pending}>
|
||||
Envoyer un test
|
||||
</Button>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
{state.message ? (
|
||||
<p className="w-full text-xs text-ok-soft-ink">{state.message}</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
name,
|
||||
hint,
|
||||
...rest
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
hint?: string;
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
const id = `email-${name}`;
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
{...rest}
|
||||
/>
|
||||
{hint ? <p className="text-micro text-ink-3">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export const NAVIGATION: NavSection[] = [
|
||||
items: [
|
||||
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
|
||||
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
|
||||
{ id: 'email', label: 'Envoi de courrier', href: '/reglages/email' },
|
||||
{ id: 'convention', label: 'Convention collective' },
|
||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||
{ id: 'roles', label: 'Rôles et permissions' },
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Composition des messages sortants.
|
||||
*
|
||||
* Séparé de l'envoi : la mise en forme d'un message se teste sans serveur SMTP,
|
||||
* et c'est là que se logent les erreurs qui font finir un message en
|
||||
* indésirable — un expéditeur mal formé, un sujet vide, un lien relatif.
|
||||
*/
|
||||
|
||||
export interface Sender {
|
||||
name: string;
|
||||
address: string;
|
||||
replyTo?: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse d'expéditeur au format RFC 5322.
|
||||
*
|
||||
* Le nom est mis entre guillemets dès qu'il contient un caractère spécial :
|
||||
* « Frouard Distribution, RH » sans guillemets casse l'en-tête à la virgule et
|
||||
* le message part avec un second destinataire fantôme.
|
||||
*/
|
||||
export function formatSender(sender: Sender): string {
|
||||
const escaped = sender.name.replace(/["\\]/g, '\\$&');
|
||||
return /[",;:<>@[\]\\]/.test(sender.name)
|
||||
? `"${escaped}" <${sender.address}>`
|
||||
: `${sender.name} <${sender.address}>`;
|
||||
}
|
||||
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
export function isEmailAddress(value: string): boolean {
|
||||
return EMAIL.test(value.trim());
|
||||
}
|
||||
|
||||
/** Échappe le texte destiné au corps HTML. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
title: string;
|
||||
intro: string;
|
||||
/** Paragraphes du corps. */
|
||||
body: string[];
|
||||
action?: { label: string; url: string };
|
||||
footer: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gabarit unique, en HTML et en texte.
|
||||
*
|
||||
* Les deux versions sont produites **du même contenu** : un message dont la
|
||||
* version texte diffère du HTML est un message qu'on n'a pas relu, et beaucoup
|
||||
* de filtres anti-spam le remarquent avant le destinataire.
|
||||
*
|
||||
* Aucune image, aucune ressource distante — la charte de télémétrie de
|
||||
* PLAN.md §3.7 s'applique aussi au courrier : un pixel de suivi dans un
|
||||
* message RH est une collecte que personne n'a acceptée.
|
||||
*/
|
||||
export function render(layout: Layout): { text: string; html: string } {
|
||||
const text = [
|
||||
layout.title,
|
||||
'',
|
||||
layout.intro,
|
||||
'',
|
||||
...layout.body,
|
||||
layout.action ? `\n${layout.action.label} : ${layout.action.url}` : '',
|
||||
'',
|
||||
'—',
|
||||
layout.footer,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="fr">
|
||||
<body style="margin:0;padding:24px;background:#f6f5f3;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:#1c1b19">
|
||||
<div style="max-width:560px;margin:0 auto;background:#ffffff;border:1px solid #e5e2dd;border-radius:12px;padding:24px">
|
||||
<h1 style="margin:0 0 12px;font-size:18px;font-weight:600">${escapeHtml(layout.title)}</h1>
|
||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.5">${escapeHtml(layout.intro)}</p>
|
||||
${layout.body
|
||||
.map(
|
||||
(paragraph) =>
|
||||
`<p style="margin:0 0 12px;font-size:14px;line-height:1.5">${escapeHtml(paragraph)}</p>`,
|
||||
)
|
||||
.join('\n ')}
|
||||
${
|
||||
layout.action
|
||||
? `<p style="margin:20px 0"><a href="${escapeHtml(layout.action.url)}" style="display:inline-block;background:#3f6f5f;color:#ffffff;text-decoration:none;padding:10px 16px;border-radius:8px;font-size:14px">${escapeHtml(layout.action.label)}</a></p>
|
||||
<p style="margin:0 0 12px;font-size:12px;color:#6b6862;word-break:break-all">${escapeHtml(layout.action.url)}</p>`
|
||||
: ''
|
||||
}
|
||||
<p style="margin:20px 0 0;padding-top:16px;border-top:1px solid #e5e2dd;font-size:12px;color:#6b6862">${escapeHtml(layout.footer)}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return { text, html };
|
||||
}
|
||||
|
||||
export interface InvitationInput {
|
||||
firstName: string;
|
||||
accountName: string;
|
||||
url: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
export function invitationMessage(
|
||||
to: string,
|
||||
input: InvitationInput,
|
||||
): Omit<Message, 'to'> & { to: string } {
|
||||
const expiry = input.expiresAt.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const { text, html } = render({
|
||||
title: `Votre accès à PlanFlow — ${input.accountName}`,
|
||||
intro: `Bonjour ${input.firstName},`,
|
||||
body: [
|
||||
`${input.accountName} vous a ouvert un accès à PlanFlow pour consulter vos plannings, poser vos congés et suivre vos compteurs.`,
|
||||
`Ce lien est personnel et expire le ${expiry}.`,
|
||||
],
|
||||
action: { label: 'Définir mon mot de passe', url: input.url },
|
||||
footer:
|
||||
'Si vous n’attendiez pas ce message, ignorez-le : sans action de votre part, aucun compte n’est activé.',
|
||||
});
|
||||
|
||||
return {
|
||||
to,
|
||||
subject: `Votre accès à PlanFlow — ${input.accountName}`,
|
||||
text,
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
export function testMessage(to: string, accountName: string): Message {
|
||||
const { text, html } = render({
|
||||
title: 'Test d’envoi PlanFlow',
|
||||
intro: 'Ce message confirme que l’envoi de courrier fonctionne.',
|
||||
body: [
|
||||
`Il a été émis depuis la configuration de ${accountName}.`,
|
||||
'Si vous le recevez dans les indésirables, vérifiez que l’adresse d’expéditeur appartient bien à un domaine que votre serveur est autorisé à signer (SPF, DKIM).',
|
||||
],
|
||||
footer: 'Message de test — aucune action attendue.',
|
||||
});
|
||||
|
||||
return { to, subject: 'Test d’envoi PlanFlow', text, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* Message d'erreur d'envoi, débarrassé de tout secret.
|
||||
*
|
||||
* Une erreur SMTP contient volontiers l'identifiant, parfois la commande
|
||||
* complète. L'afficher à l'écran ou l'écrire au journal exposerait le mot de
|
||||
* passe du serveur d'envoi.
|
||||
*/
|
||||
export function redactSmtpError(error: unknown, username?: string | null): string {
|
||||
let message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Les serveurs renvoient souvent la commande AUTH en clair dans l'erreur.
|
||||
message = message.replace(/AUTH\s+\S+\s+\S+/gi, 'AUTH [masqué]');
|
||||
message = message.replace(/(pass(word)?|pwd)\s*[:=]\s*\S+/gi, '$1 [masqué]');
|
||||
if (username) {
|
||||
message = message.split(username).join('[identifiant]');
|
||||
}
|
||||
|
||||
return message.slice(0, 500);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { isEmailAddress, testMessage } from '@/domain/email/message';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { encryptOptional } from '@/server/crypto';
|
||||
import { loadSmtpConfig, sendEmail, verifySmtp } from '@/server/email/mailer';
|
||||
|
||||
/**
|
||||
* Configuration du serveur d'envoi.
|
||||
*
|
||||
* Le mot de passe est traité comme un secret, au même titre que le NIR et
|
||||
* l'IBAN : chiffré au repos, jamais renvoyé à l'écran, jamais recopié dans un
|
||||
* message d'erreur. Un accès SMTP volé permet d'écrire au nom de l'entreprise.
|
||||
*/
|
||||
|
||||
export interface EmailActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const settingsInput = z.object({
|
||||
host: z.string().trim().min(1, 'Serveur requis').max(255),
|
||||
port: z.coerce.number().int().min(1).max(65535),
|
||||
secure: z.boolean(),
|
||||
username: z.string().trim().max(255).optional(),
|
||||
/** Vide = on conserve le mot de passe déjà enregistré. */
|
||||
password: z.string().max(255).optional(),
|
||||
fromName: z.string().trim().min(1, 'Nom d’expéditeur requis').max(120),
|
||||
fromAddress: z.string().trim().min(1, 'Adresse d’expéditeur requise').max(255),
|
||||
replyTo: z.string().trim().max(255).optional(),
|
||||
});
|
||||
|
||||
export async function saveEmailSettingsAction(
|
||||
_previous: EmailActionState,
|
||||
formData: FormData,
|
||||
): Promise<EmailActionState> {
|
||||
const parsed = settingsInput.safeParse({
|
||||
host: formData.get('host'),
|
||||
port: formData.get('port') ?? 587,
|
||||
secure: formData.get('secure') === 'on',
|
||||
username: formData.get('username') || undefined,
|
||||
password: formData.get('password') || undefined,
|
||||
fromName: formData.get('fromName'),
|
||||
fromAddress: formData.get('fromAddress'),
|
||||
replyTo: formData.get('replyTo') || undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
if (!isEmailAddress(parsed.data.fromAddress)) {
|
||||
return { error: 'L’adresse d’expéditeur n’est pas une adresse valide.' };
|
||||
}
|
||||
if (parsed.data.replyTo && !isEmailAddress(parsed.data.replyTo)) {
|
||||
return { error: 'L’adresse de réponse n’est pas une adresse valide.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.notifications.manage', async (db, actor) => {
|
||||
const existing = await db.emailSettings.findFirst();
|
||||
|
||||
// Un mot de passe laissé vide **conserve** l'ancien : le formulaire ne
|
||||
// peut pas le pré-remplir puisqu'il ne le reçoit jamais, et le vider à
|
||||
// chaque enregistrement casserait l'envoi au premier changement de port.
|
||||
const passwordEnc = parsed.data.password
|
||||
? encryptOptional(parsed.data.password)
|
||||
: (existing?.passwordEnc ?? null);
|
||||
|
||||
const data = {
|
||||
host: parsed.data.host,
|
||||
port: parsed.data.port,
|
||||
secure: parsed.data.secure,
|
||||
username: parsed.data.username ?? null,
|
||||
passwordEnc,
|
||||
fromName: parsed.data.fromName,
|
||||
fromAddress: parsed.data.fromAddress,
|
||||
replyTo: parsed.data.replyTo ?? null,
|
||||
// Toute modification invalide la vérification : un réglage changé n'est
|
||||
// plus le réglage éprouvé.
|
||||
verifiedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db.emailSettings.update({
|
||||
where: { id: existing.id },
|
||||
data: data as never,
|
||||
});
|
||||
} else {
|
||||
await db.emailSettings.create({ data: data as never });
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'settings.email.update',
|
||||
entityType: 'EmailSettings',
|
||||
entityId: existing?.id ?? 'nouveau',
|
||||
before: existing
|
||||
? { host: existing.host, port: existing.port, from: existing.fromAddress }
|
||||
: null,
|
||||
// Le mot de passe n'apparaît pas, même chiffré : un journal d'audit se
|
||||
// lit plus largement qu'une table de réglages.
|
||||
after: {
|
||||
host: parsed.data.host,
|
||||
port: parsed.data.port,
|
||||
from: parsed.data.fromAddress,
|
||||
passwordChanged: Boolean(parsed.data.password),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit de modifier ces réglages.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/email');
|
||||
return { ok: true, message: 'Réglages enregistrés. Faites un envoi de test.' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoi de test.
|
||||
*
|
||||
* Deux étapes distinctes : `verify` ouvre la connexion et authentifie, ce qui
|
||||
* sépare une adresse de serveur fautive d'un mot de passe faux ; puis l'envoi
|
||||
* réel, qui seul prouve que le message sort.
|
||||
*/
|
||||
export async function sendTestEmailAction(
|
||||
_previous: EmailActionState,
|
||||
formData: FormData,
|
||||
): Promise<EmailActionState> {
|
||||
const to = String(formData.get('to') ?? '').trim();
|
||||
if (!isEmailAddress(to)) {
|
||||
return { error: 'Indiquez une adresse de destination valide.' };
|
||||
}
|
||||
|
||||
let outcome: EmailActionState = { ok: true };
|
||||
|
||||
try {
|
||||
await mutate('settings.notifications.manage', async (db, actor) => {
|
||||
const config = await loadSmtpConfig(db);
|
||||
if (!config) {
|
||||
throw new ValidationError(
|
||||
'Enregistrez d’abord les réglages du serveur d’envoi.',
|
||||
);
|
||||
}
|
||||
|
||||
const account = await db.account.findFirst({ select: { name: true } });
|
||||
const checked = await verifySmtp(config);
|
||||
|
||||
if (!checked.ok) {
|
||||
await db.emailSettings.updateMany({
|
||||
data: { verifiedAt: null, lastError: checked.error ?? 'Échec' },
|
||||
});
|
||||
outcome = {
|
||||
error: `Connexion au serveur impossible — ${checked.error ?? 'raison inconnue'}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await sendEmail(
|
||||
db,
|
||||
testMessage(to, account?.name ?? 'PlanFlow'),
|
||||
'TEST',
|
||||
);
|
||||
|
||||
await db.emailSettings.updateMany({
|
||||
data: {
|
||||
verifiedAt: result.ok ? new Date() : null,
|
||||
lastError: result.ok ? null : (result.error ?? 'Échec'),
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'settings.email.test',
|
||||
entityType: 'EmailSettings',
|
||||
entityId: 'test',
|
||||
after: { to, ok: result.ok },
|
||||
});
|
||||
|
||||
outcome = result.ok
|
||||
? { ok: true, message: `Message envoyé à ${to}.` }
|
||||
: { error: `Envoi refusé — ${result.error ?? 'raison inconnue'}` };
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, "Vous n'avez pas le droit d'envoyer un test.");
|
||||
}
|
||||
|
||||
revalidatePath('/reglages/email');
|
||||
return outcome;
|
||||
}
|
||||
|
||||
function toState(error: unknown, denied: string): EmailActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import 'server-only';
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
import {
|
||||
formatSender,
|
||||
redactSmtpError,
|
||||
type Message,
|
||||
type Sender,
|
||||
} from '@/domain/email/message';
|
||||
import { decryptOptional } from '@/server/crypto';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Envoi de courrier — configuration par compte.
|
||||
*
|
||||
* Les réglages vivent en base, pas dans l'environnement : chaque client
|
||||
* auto-hébergé a sa propre boîte, et en changer ne doit exiger ni accès au
|
||||
* serveur ni redémarrage.
|
||||
*
|
||||
* Le mot de passe est chiffré au repos et **n'est déchiffré qu'ici**, au moment
|
||||
* d'ouvrir la connexion. Il ne remonte jamais vers un écran, jamais vers un
|
||||
* journal, jamais vers un message d'erreur.
|
||||
*/
|
||||
|
||||
export interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
sender: Sender;
|
||||
}
|
||||
|
||||
export type EmailKind =
|
||||
| 'TEST'
|
||||
| 'INVITATION'
|
||||
| 'PASSWORD_RESET'
|
||||
| 'PLANNING_PUBLISHED'
|
||||
| 'TIMEOFF_DECISION'
|
||||
| 'LEAVE_NOTICE';
|
||||
|
||||
export interface SendResult {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Charge la configuration du compte, mot de passe déchiffré. */
|
||||
export async function loadSmtpConfig(
|
||||
db: ScopedClient,
|
||||
): Promise<SmtpConfig | null> {
|
||||
const settings = await db.emailSettings.findFirst();
|
||||
if (!settings) return null;
|
||||
|
||||
return {
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: settings.secure,
|
||||
username: settings.username,
|
||||
password: decryptOptional(settings.passwordEnc),
|
||||
sender: {
|
||||
name: settings.fromName,
|
||||
address: settings.fromAddress,
|
||||
replyTo: settings.replyTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un message et journalise le résultat.
|
||||
*
|
||||
* Le corps n'est **pas** conservé : il porte des données personnelles et se
|
||||
* régénère. Ce qui compte, quand un salarié dit n'avoir rien reçu, c'est de
|
||||
* savoir si le serveur a accepté le message et à quelle adresse.
|
||||
*/
|
||||
export async function sendEmail(
|
||||
db: ScopedClient,
|
||||
message: Message,
|
||||
kind: EmailKind,
|
||||
membershipId?: string | null,
|
||||
): Promise<SendResult> {
|
||||
const config = await loadSmtpConfig(db);
|
||||
|
||||
if (!config) {
|
||||
const error =
|
||||
'Aucun serveur d’envoi configuré. Réglages · Envoi de courrier.';
|
||||
await logEmail(db, message, kind, 'FAILED', { error, membershipId });
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transportFor(config).sendMail({
|
||||
from: formatSender(config.sender),
|
||||
...(config.sender.replyTo ? { replyTo: config.sender.replyTo } : {}),
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
html: message.html,
|
||||
});
|
||||
|
||||
await logEmail(db, message, kind, 'SENT', {
|
||||
messageId: result.messageId,
|
||||
membershipId,
|
||||
});
|
||||
return { ok: true, messageId: result.messageId };
|
||||
} catch (error) {
|
||||
const redacted = redactSmtpError(error, config.username);
|
||||
await logEmail(db, message, kind, 'FAILED', {
|
||||
error: redacted,
|
||||
membershipId,
|
||||
});
|
||||
return { ok: false, error: redacted };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la configuration sans envoyer.
|
||||
*
|
||||
* `verify` ouvre la connexion et authentifie : c'est ce qui distingue une
|
||||
* adresse de serveur fautive d'un mot de passe faux, alors qu'un envoi raté ne
|
||||
* dit que « ça n'a pas marché ».
|
||||
*/
|
||||
export async function verifySmtp(config: SmtpConfig): Promise<SendResult> {
|
||||
try {
|
||||
await transportFor(config).verify();
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: redactSmtpError(error, config.username) };
|
||||
}
|
||||
}
|
||||
|
||||
function transportFor(config: SmtpConfig) {
|
||||
return nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
// 465 chiffre dès la connexion ; 587 négocie STARTTLS. Se tromper produit
|
||||
// une erreur illisible côté serveur.
|
||||
secure: config.secure,
|
||||
...(config.username && config.password
|
||||
? { auth: { user: config.username, pass: config.password } }
|
||||
: {}),
|
||||
// Ne jamais accepter un certificat invalide : un envoi RH transporte des
|
||||
// noms et des adresses.
|
||||
tls: { rejectUnauthorized: true },
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function logEmail(
|
||||
db: ScopedClient,
|
||||
message: Message,
|
||||
kind: EmailKind,
|
||||
status: 'SENT' | 'FAILED',
|
||||
extra: {
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
membershipId?: string | null | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db.emailLog.create({
|
||||
data: {
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
kind,
|
||||
status,
|
||||
error: extra.error ?? null,
|
||||
messageId: extra.messageId ?? null,
|
||||
membershipId: extra.membershipId ?? null,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user