Configurer le serveur d'envoi de courrier

Sans serveur SMTP, PlanFlow ne peut ni inviter un salarié, ni notifier une
publication de planning, ni délivrer l'information due au retour d'un arrêt.
L'application n'expédie rien par elle-même : elle se connecte au serveur du
client, pour que les messages partent de son domaine et que les salariés
reconnaissent l'expéditeur.

Le mot de passe SMTP est traité comme le NIR et l'IBAN — chiffré au repos avec
la même clé, jamais renvoyé à l'écran (la page ne le charge même pas chiffré),
jamais recopié dans un message d'erreur ni dans le journal d'audit. Les erreurs
SMTP passent par une passe de masquage : les serveurs renvoient volontiers la
commande AUTH en clair. Laisser le champ vide conserve le mot de passe
enregistré, sans quoi changer un numéro de port casserait l'envoi.

Enregistrer et éprouver sont deux gestes distincts : toute modification remet
le réglage en « non vérifié », car un réglage non éprouvé n'est pas un réglage,
c'est une intention. L'envoi de test vérifie d'abord la connexion — ce qui
sépare une adresse de serveur fautive d'un mot de passe faux — puis expédie
réellement.

Les messages sont rendus en texte et en HTML depuis le même contenu, sans
image, script ni ressource distante : la charte de télémétrie vaut aussi pour
le courrier, un pixel de suivi dans un message RH est une collecte que personne
n'a acceptée. Le journal d'envoi retient le destinataire et l'issue, pas le
corps du message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 13:01:41 +00:00
1 parent 153a9cc382
commit c3f7299f6c
12 files changed
+1362

No files matched your search

@@ -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();
+71
View File
@@ -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
}