Merge pull request #17 from R0m1k3/claude/combohr-app-recreation-plan-yu7wre
Configurer le serveur d'envoi de courrier
This commit is contained in:
12 files changed
+1362
No files matched your search
@@ -31,6 +31,7 @@
|
|||||||
"@prisma/adapter-pg": "7.9.1",
|
"@prisma/adapter-pg": "7.9.1",
|
||||||
"@prisma/client": "7.9.1",
|
"@prisma/client": "7.9.1",
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
|
"nodemailer": "^9.0.5",
|
||||||
"pg": "8.22.0",
|
"pg": "8.22.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@tailwindcss/postcss": "4.3.3",
|
"@tailwindcss/postcss": "4.3.3",
|
||||||
"@types/node": "22.19.4",
|
"@types/node": "22.19.4",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/pg": "8.20.4",
|
"@types/pg": "8.20.4",
|
||||||
"@types/react": "19.2.18",
|
"@types/react": "19.2.18",
|
||||||
"@types/react-dom": "19.2.4",
|
"@types/react-dom": "19.2.4",
|
||||||
|
|||||||
Generated
+19
@@ -20,6 +20,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.3.0
|
specifier: 16.3.0
|
||||||
version: 16.3.0(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.19.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 16.3.0(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@22.19.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^9.0.5
|
||||||
|
version: 9.0.5
|
||||||
pg:
|
pg:
|
||||||
specifier: 8.22.0
|
specifier: 8.22.0
|
||||||
version: 8.22.0
|
version: 8.22.0
|
||||||
@@ -42,6 +45,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: 22.19.4
|
specifier: 22.19.4
|
||||||
version: 22.19.4
|
version: 22.19.4
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^8.0.1
|
||||||
|
version: 8.0.1
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: 8.20.4
|
specifier: 8.20.4
|
||||||
version: 8.20.4
|
version: 8.20.4
|
||||||
@@ -1153,6 +1159,9 @@ packages:
|
|||||||
'@types/node@22.19.4':
|
'@types/node@22.19.4':
|
||||||
resolution: {integrity: sha512-0yy7kLo3lc2S+O1wsTqWBjU8CX1OExoIdLWz0EM9i3Tqj9qXWG8yjDEN2ZB/coCHc0j/OQXc81gwJYHX7+uTMg==}
|
resolution: {integrity: sha512-0yy7kLo3lc2S+O1wsTqWBjU8CX1OExoIdLWz0EM9i3Tqj9qXWG8yjDEN2ZB/coCHc0j/OQXc81gwJYHX7+uTMg==}
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.1':
|
||||||
|
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||||
|
|
||||||
'@types/pg@8.20.4':
|
'@types/pg@8.20.4':
|
||||||
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
|
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
|
||||||
|
|
||||||
@@ -2563,6 +2572,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
|
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
nodemailer@9.0.5:
|
||||||
|
resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4127,6 +4140,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/nodemailer@8.0.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.4
|
||||||
|
|
||||||
'@types/pg@8.20.4':
|
'@types/pg@8.20.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.4
|
'@types/node': 22.19.4
|
||||||
@@ -5708,6 +5725,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.53: {}
|
node-releases@2.0.53: {}
|
||||||
|
|
||||||
|
nodemailer@9.0.5: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EmailKind" AS ENUM ('TEST', 'INVITATION', 'PASSWORD_RESET', 'PLANNING_PUBLISHED', 'TIMEOFF_DECISION', 'LEAVE_NOTICE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EmailStatus" AS ENUM ('SENT', 'FAILED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EmailSettings" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"host" TEXT NOT NULL,
|
||||||
|
"port" INTEGER NOT NULL DEFAULT 587,
|
||||||
|
"secure" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"username" TEXT,
|
||||||
|
"passwordEnc" BYTEA,
|
||||||
|
"fromName" TEXT NOT NULL,
|
||||||
|
"fromAddress" TEXT NOT NULL,
|
||||||
|
"replyTo" TEXT,
|
||||||
|
"verifiedAt" TIMESTAMP(3),
|
||||||
|
"lastError" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailSettings_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "EmailLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"to" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"kind" "EmailKind" NOT NULL,
|
||||||
|
"status" "EmailStatus" NOT NULL,
|
||||||
|
"error" TEXT,
|
||||||
|
"messageId" TEXT,
|
||||||
|
"membershipId" TEXT,
|
||||||
|
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "EmailLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "EmailSettings_accountId_key" ON "EmailSettings"("accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EmailSettings_accountId_idx" ON "EmailSettings"("accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "EmailLog_accountId_sentAt_idx" ON "EmailLog"("accountId", "sentAt");
|
||||||
|
|
||||||
|
-- Isolation : toute table portant accountId doit porter sa politique.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH t IN ARRAY ARRAY['EmailSettings','EmailLog']
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||||
|
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())', t);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())', t);
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Le journal d'envoi atteste de ce qui a été transmis : il ne se réécrit pas.
|
||||||
|
CREATE TRIGGER email_log_append_only
|
||||||
|
BEFORE UPDATE OR DELETE ON "EmailLog"
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION planflow_deny_write();
|
||||||
@@ -1094,3 +1094,74 @@ model PayPeriodSnapshot {
|
|||||||
@@unique([payPeriodId, membershipId])
|
@@unique([payPeriodId, membershipId])
|
||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Envoi d'e-mails — configuration par compte
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Serveur d'envoi du compte.
|
||||||
|
///
|
||||||
|
/// En base et non dans les variables d'environnement : chaque client
|
||||||
|
/// auto-hébergé a sa propre boîte, et la changer ne doit pas exiger un accès au
|
||||||
|
/// serveur ni un redémarrage.
|
||||||
|
///
|
||||||
|
/// Le mot de passe est **chiffré au repos** avec la même clé que le NIR et
|
||||||
|
/// l'IBAN (PLAN.md §3.6). Un accès SMTP volé permet d'écrire au nom de
|
||||||
|
/// l'entreprise : c'est un secret, pas un réglage.
|
||||||
|
model EmailSettings {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
accountId String @unique
|
||||||
|
host String
|
||||||
|
port Int @default(587)
|
||||||
|
/// STARTTLS sur 587, TLS implicite sur 465.
|
||||||
|
secure Boolean @default(false)
|
||||||
|
username String?
|
||||||
|
passwordEnc Bytes?
|
||||||
|
/// Expéditeur affiché. Doit appartenir au domaine authentifié, sinon les
|
||||||
|
/// messages partent en indésirable.
|
||||||
|
fromName String
|
||||||
|
fromAddress String
|
||||||
|
replyTo String?
|
||||||
|
/// Faux tant qu'un envoi de test n'a pas abouti : un réglage non éprouvé
|
||||||
|
/// n'est pas un réglage, c'est une intention.
|
||||||
|
verifiedAt DateTime?
|
||||||
|
lastError String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trace d'un message émis.
|
||||||
|
///
|
||||||
|
/// Sans elle, « le salarié dit qu'il n'a rien reçu » est indécidable. Le corps
|
||||||
|
/// n'est pas conservé : il porte des données personnelles et se régénère.
|
||||||
|
model EmailLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
accountId String
|
||||||
|
to String
|
||||||
|
subject String
|
||||||
|
kind EmailKind
|
||||||
|
status EmailStatus
|
||||||
|
error String?
|
||||||
|
/// Identifiant rendu par le serveur d'envoi, utile au dépannage.
|
||||||
|
messageId String?
|
||||||
|
membershipId String?
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([accountId, sentAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmailKind {
|
||||||
|
TEST
|
||||||
|
INVITATION
|
||||||
|
PASSWORD_RESET
|
||||||
|
PLANNING_PUBLISHED
|
||||||
|
TIMEOFF_DECISION
|
||||||
|
LEAVE_NOTICE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EmailStatus {
|
||||||
|
SENT
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
@@ -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: [
|
items: [
|
||||||
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
|
{ id: 'sites', label: 'Établissements', href: '/reglages/etablissements' },
|
||||||
{ id: 'registre', label: 'Registre de paramétrage', href: '/reglages/registre' },
|
{ 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: 'convention', label: 'Convention collective' },
|
||||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||||
{ id: 'roles', label: 'Rôles et permissions' },
|
{ 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Réglages d'envoi de courrier.
|
||||||
|
*
|
||||||
|
* Deux points à protéger : le mot de passe SMTP ne doit **jamais** revenir vers
|
||||||
|
* l'écran, et un réglage non éprouvé ne doit pas se faire passer pour un
|
||||||
|
* réglage qui marche.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test('l’écran explique pourquoi ce réglage est nécessaire', async ({ page }) => {
|
||||||
|
await page.goto('/reglages/email');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: 'Envoi de courrier' }),
|
||||||
|
).toBeVisible();
|
||||||
|
// PlanFlow n'envoie rien par lui-même : les messages partent du domaine du
|
||||||
|
// client, et le dire évite la question « depuis quelle adresse ? ».
|
||||||
|
await expect(page.getByText(/votre.+serveur de messagerie/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un réglage non testé est annoncé comme non vérifié', async ({ page }) => {
|
||||||
|
await page.goto('/reglages/email');
|
||||||
|
// Un réglage non éprouvé n'est pas un réglage, c'est une intention.
|
||||||
|
await expect(page.getByText('Non vérifié')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le mot de passe n’est jamais renvoyé à l’écran', async ({ page }) => {
|
||||||
|
await page.goto('/reglages/email');
|
||||||
|
|
||||||
|
const secret = `secret-${Date.now()}`;
|
||||||
|
const form = page.locator('form').filter({ hasText: 'Enregistrer' });
|
||||||
|
|
||||||
|
await form.getByLabel('Serveur SMTP').fill('smtp.exemple.test');
|
||||||
|
await form.getByLabel('Port', { exact: true }).fill('587');
|
||||||
|
await form.getByLabel('Identifiant').fill('rh@exemple.test');
|
||||||
|
await form.getByLabel('Mot de passe').fill(secret);
|
||||||
|
await form.getByLabel('Nom d’expéditeur').fill('Test PlanFlow');
|
||||||
|
await form.getByLabel('Adresse d’expéditeur').fill('rh@exemple.test');
|
||||||
|
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/Réglages enregistrés/)).toBeVisible();
|
||||||
|
|
||||||
|
// Le secret ne doit apparaître nulle part dans le HTML servi, ni en clair ni
|
||||||
|
// dans une valeur de champ.
|
||||||
|
await page.reload();
|
||||||
|
expect(await page.content()).not.toContain(secret);
|
||||||
|
await expect(form.getByLabel('Mot de passe')).toHaveValue('');
|
||||||
|
await expect(form.getByLabel('Mot de passe')).toHaveAttribute(
|
||||||
|
'placeholder',
|
||||||
|
/inchangé/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('une adresse d’expéditeur invalide est refusée', async ({ page }) => {
|
||||||
|
await page.goto('/reglages/email');
|
||||||
|
|
||||||
|
const form = page.locator('form').filter({ hasText: 'Enregistrer' });
|
||||||
|
await form.getByLabel('Serveur SMTP').fill('smtp.exemple.test');
|
||||||
|
await form.getByLabel('Nom d’expéditeur').fill('Test');
|
||||||
|
// `type="email"` du navigateur bloquerait une saisie manifestement fausse :
|
||||||
|
// on éprouve ici le contrôle serveur, avec une forme qui passe le premier.
|
||||||
|
await form.getByLabel('Adresse d’expéditeur').fill('rh@exemple');
|
||||||
|
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
form.getByText(/adresse d’expéditeur n’est pas une adresse valide/),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un envoi de test vers un serveur injoignable échoue proprement', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/reglages/email');
|
||||||
|
|
||||||
|
const form = page.locator('form').filter({ hasText: 'Enregistrer' });
|
||||||
|
await form.getByLabel('Serveur SMTP').fill('smtp.invalide.test');
|
||||||
|
await form.getByLabel('Port', { exact: true }).fill('587');
|
||||||
|
await form.getByLabel('Nom d’expéditeur').fill('Test PlanFlow');
|
||||||
|
await form.getByLabel('Adresse d’expéditeur').fill('rh@exemple.test');
|
||||||
|
await form.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
await expect(page.getByText(/Réglages enregistrés/)).toBeVisible();
|
||||||
|
|
||||||
|
const testForm = page.locator('form').filter({ hasText: 'Envoyer un test' });
|
||||||
|
await testForm.getByLabel('Envoyer un test à').fill('destinataire@exemple.test');
|
||||||
|
await testForm.getByRole('button', { name: 'Envoyer un test' }).click();
|
||||||
|
|
||||||
|
// L'échec est annoncé, pas avalé : un réglage muet se croit fonctionnel.
|
||||||
|
await expect(testForm.getByText(/Connexion au serveur impossible/)).toBeVisible(
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
escapeHtml,
|
||||||
|
formatSender,
|
||||||
|
invitationMessage,
|
||||||
|
isEmailAddress,
|
||||||
|
redactSmtpError,
|
||||||
|
render,
|
||||||
|
testMessage,
|
||||||
|
} from '@/domain/email/message';
|
||||||
|
|
||||||
|
describe('adresse d’expéditeur', () => {
|
||||||
|
it('compose un en-tête simple', () => {
|
||||||
|
expect(formatSender({ name: 'Maison Rivage', address: 'rh@example.fr' })).toBe(
|
||||||
|
'Maison Rivage <rh@example.fr>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('met le nom entre guillemets dès qu’il contient une virgule', () => {
|
||||||
|
// Sans guillemets, l'en-tête casse à la virgule et le message part avec un
|
||||||
|
// second destinataire fantôme.
|
||||||
|
expect(
|
||||||
|
formatSender({ name: 'Maison Rivage, RH', address: 'rh@example.fr' }),
|
||||||
|
).toBe('"Maison Rivage, RH" <rh@example.fr>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappe les guillemets du nom', () => {
|
||||||
|
expect(
|
||||||
|
formatSender({ name: 'Le "Comptoir"', address: 'rh@example.fr' }),
|
||||||
|
).toBe('"Le \\"Comptoir\\"" <rh@example.fr>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('met entre guillemets un nom contenant un chevron', () => {
|
||||||
|
// Un chevron non protégé ouvrirait une seconde adresse dans l'en-tête.
|
||||||
|
expect(
|
||||||
|
formatSender({ name: 'RH <interne>', address: 'rh@example.fr' }),
|
||||||
|
).toBe('"RH <interne>" <rh@example.fr>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation d’adresse', () => {
|
||||||
|
it('accepte les formes usuelles', () => {
|
||||||
|
expect(isEmailAddress('rh@example.fr')).toBe(true);
|
||||||
|
expect(isEmailAddress('prenom.nom+rh@sous.example.co.uk')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse ce qui n’en est pas une', () => {
|
||||||
|
expect(isEmailAddress('rh@example')).toBe(false);
|
||||||
|
expect(isEmailAddress('rh example.fr')).toBe(false);
|
||||||
|
expect(isEmailAddress('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gabarit', () => {
|
||||||
|
const layout = {
|
||||||
|
title: 'Titre',
|
||||||
|
intro: 'Bonjour Rémi,',
|
||||||
|
body: ['Premier paragraphe.', 'Second paragraphe.'],
|
||||||
|
action: { label: 'Ouvrir', url: 'https://exemple.test/a?b=c' },
|
||||||
|
footer: 'Pied de message.',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('produit les deux versions 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 le remarquent avant le destinataire.
|
||||||
|
const { text, html } = render(layout);
|
||||||
|
for (const paragraph of layout.body) {
|
||||||
|
expect(text).toContain(paragraph);
|
||||||
|
expect(html).toContain(paragraph);
|
||||||
|
}
|
||||||
|
expect(text).toContain(layout.action.url);
|
||||||
|
expect(html).toContain(layout.action.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rend le lien en clair dans la version texte', () => {
|
||||||
|
const { text } = render(layout);
|
||||||
|
expect(text).toContain('Ouvrir : https://exemple.test/a?b=c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('n’embarque aucune 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.
|
||||||
|
const { html } = render(layout);
|
||||||
|
expect(html).not.toMatch(/<img/i);
|
||||||
|
expect(html).not.toMatch(/<script/i);
|
||||||
|
expect(html).not.toMatch(/url\(/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('échappe le HTML du contenu', () => {
|
||||||
|
const { html } = render({
|
||||||
|
...layout,
|
||||||
|
intro: 'Bonjour <script>alert(1)</script>',
|
||||||
|
});
|
||||||
|
expect(html).not.toContain('<script>alert(1)</script>');
|
||||||
|
expect(html).toContain('<script>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('échappement', () => {
|
||||||
|
it('traite les cinq caractères qui comptent', () => {
|
||||||
|
expect(escapeHtml('<a href="x">&</a>')).toBe(
|
||||||
|
'<a href="x">&</a>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message d’invitation', () => {
|
||||||
|
const message = invitationMessage('salarie@example.test', {
|
||||||
|
firstName: 'Rémi',
|
||||||
|
accountName: 'Maison Rivage',
|
||||||
|
url: 'https://planflow.example/invitation/abc',
|
||||||
|
expiresAt: new Date('2026-09-15T00:00:00Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nomme le destinataire et l’entreprise', () => {
|
||||||
|
expect(message.to).toBe('salarie@example.test');
|
||||||
|
expect(message.text).toContain('Rémi');
|
||||||
|
expect(message.text).toContain('Maison Rivage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annonce la date d’expiration', () => {
|
||||||
|
// Un lien qui expire sans le dire produit un appel au support.
|
||||||
|
expect(message.text).toContain('15 septembre 2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dit quoi faire si le message n’était pas attendu', () => {
|
||||||
|
expect(message.text).toMatch(/ignorez-le/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message de test', () => {
|
||||||
|
it('explique le cas des indésirables', () => {
|
||||||
|
// C'est la première difficulté rencontrée en configurant un envoi : le
|
||||||
|
// message part mais n'arrive pas.
|
||||||
|
const message = testMessage('rh@example.fr', 'Maison Rivage');
|
||||||
|
expect(message.text).toMatch(/SPF, DKIM/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('erreurs SMTP', () => {
|
||||||
|
it('masque la commande d’authentification', () => {
|
||||||
|
// Les serveurs renvoient volontiers la commande AUTH en clair : la
|
||||||
|
// recopier à l'écran ou au journal exposerait le mot de passe.
|
||||||
|
const redacted = redactSmtpError(
|
||||||
|
new Error('Invalid command: AUTH PLAIN AHJoQGV4YW1wbGUuZnIAczNjcmV0'),
|
||||||
|
);
|
||||||
|
expect(redacted).toContain('AUTH [masqué]');
|
||||||
|
expect(redacted).not.toContain('AHJoQGV4YW1wbGUuZnIAczNjcmV0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('masque un mot de passe nommé', () => {
|
||||||
|
expect(redactSmtpError(new Error('login failed: password=s3cret'))).toBe(
|
||||||
|
'login failed: password [masqué]',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remplace l’identifiant par un libellé', () => {
|
||||||
|
const redacted = redactSmtpError(
|
||||||
|
new Error('535 Authentication failed for rh@example.fr'),
|
||||||
|
'rh@example.fr',
|
||||||
|
);
|
||||||
|
expect(redacted).toContain('[identifiant]');
|
||||||
|
expect(redacted).not.toContain('rh@example.fr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('borne la longueur du message', () => {
|
||||||
|
const redacted = redactSmtpError(new Error('x'.repeat(2000)));
|
||||||
|
expect(redacted.length).toBeLessThanOrEqual(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte une valeur qui n’est pas une erreur', () => {
|
||||||
|
expect(redactSmtpError('ECONNREFUSED')).toBe('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user