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/client": "7.9.1",
|
||||
"next": "16.3.0",
|
||||
"nodemailer": "^9.0.5",
|
||||
"pg": "8.22.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
@@ -40,6 +41,7 @@
|
||||
"@playwright/test": "1.62.1",
|
||||
"@tailwindcss/postcss": "4.3.3",
|
||||
"@types/node": "22.19.4",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/pg": "8.20.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.4",
|
||||
|
||||
Generated
+19
@@ -20,6 +20,9 @@ importers:
|
||||
next:
|
||||
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)
|
||||
nodemailer:
|
||||
specifier: ^9.0.5
|
||||
version: 9.0.5
|
||||
pg:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
@@ -42,6 +45,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: 22.19.4
|
||||
version: 22.19.4
|
||||
'@types/nodemailer':
|
||||
specifier: ^8.0.1
|
||||
version: 8.0.1
|
||||
'@types/pg':
|
||||
specifier: 8.20.4
|
||||
version: 8.20.4
|
||||
@@ -1153,6 +1159,9 @@ packages:
|
||||
'@types/node@22.19.4':
|
||||
resolution: {integrity: sha512-0yy7kLo3lc2S+O1wsTqWBjU8CX1OExoIdLWz0EM9i3Tqj9qXWG8yjDEN2ZB/coCHc0j/OQXc81gwJYHX7+uTMg==}
|
||||
|
||||
'@types/nodemailer@8.0.1':
|
||||
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
|
||||
|
||||
'@types/pg@8.20.4':
|
||||
resolution: {integrity: sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==}
|
||||
|
||||
@@ -2563,6 +2572,10 @@ packages:
|
||||
resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
nodemailer@9.0.5:
|
||||
resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4127,6 +4140,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/nodemailer@8.0.1':
|
||||
dependencies:
|
||||
'@types/node': 22.19.4
|
||||
|
||||
'@types/pg@8.20.4':
|
||||
dependencies:
|
||||
'@types/node': 22.19.4
|
||||
@@ -5708,6 +5725,8 @@ snapshots:
|
||||
|
||||
node-releases@2.0.53: {}
|
||||
|
||||
nodemailer@9.0.5: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
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])
|
||||
@@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: [
|
||||
{ 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,
|
||||
});
|
||||
}
|
||||
@@ -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