From 8d4443a1a1f1839d4fa436ffa0068fc90d5e25d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 16:00:11 +0000 Subject: [PATCH] =?UTF-8?q?Bo=C3=AEte=20mail=20de=20l'association=20:=20tr?= =?UTF-8?q?ois=20transports,=20une=20file=20d'attente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le site savait composer de beaux messages et ne savait pas les envoyer : `email-client.ts` ne proposait que la copie ou le téléchargement d'un brouillon Outlook, complété à la main. `/backend/boite-mail` branche désormais la boîte de l'association, sur le modèle exact de l'écran Réseaux sociaux — secrets chiffrés, jamais renvoyés au navigateur, champ laissé vide qui conserve la valeur enregistrée, contrôle de santé qui ne lève jamais. Trois transports, un seul actif à la fois, désigné par l'administrateur — pas de cascade automatique : si Google se bloque, la bascule se voit. - **Google** passe par l'API Gmail plutôt que par SMTP avec XOAUTH2 : celui-ci exigerait `https://mail.google.com/`, portée *restreinte* et donc audit de sécurité, là où `gmail.send` est simplement *sensible*. - **Microsoft** passe par Graph : l'authentification basique SMTP est désactivée depuis 2024, y compris sur outlook.com et hotmail.com. - **SMTP** couvre le reste via `nodemailer`, seule dépendance ajoutée. `from_address` est **lu chez le fournisseur** et non saisi : Gmail expédie comme l'utilisateur authentifié, Graph comme la boîte. Une adresse d'un autre domaine ferait tomber SPF et DKIM. La file `mail_messages` porte un destinataire unique par ligne — la confidentialité d'une diffusion est structurelle, aucune copie partagée n'est possible. Elle est vidée par la boucle de fond, avec réclamation en `FOR UPDATE SKIP LOCKED`, réessais espacés et reprise des verrous laissés par un conteneur arrêté en plein envoi. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP --- drizzle/0019_nostalgic_warstar.sql | 58 + drizzle/meta/0019_snapshot.json | 2073 +++++++++++++++++ drizzle/meta/_journal.json | 7 + next.config.mjs | 3 + package-lock.json | 21 + package.json | 2 + src/app/api/mail/[provider]/callback/route.ts | 84 + src/app/api/mail/[provider]/connect/route.ts | 62 + src/app/backend/BackendShell.tsx | 2 + src/app/backend/actions.ts | 157 ++ src/app/backend/boite-mail/page.tsx | 399 ++++ src/app/backend/emails/page.tsx | 11 +- src/db/schema.ts | 82 + src/instrumentation-node.ts | 34 +- src/lib/mail-accounts.ts | 532 +++++ src/lib/mail-outbox.ts | 207 ++ src/lib/mail-recipients.ts | 122 + src/lib/mailer.ts | 141 ++ src/lib/site-settings.ts | 16 + 19 files changed, 3997 insertions(+), 16 deletions(-) create mode 100644 drizzle/0019_nostalgic_warstar.sql create mode 100644 drizzle/meta/0019_snapshot.json create mode 100644 src/app/api/mail/[provider]/callback/route.ts create mode 100644 src/app/api/mail/[provider]/connect/route.ts create mode 100644 src/app/backend/boite-mail/page.tsx create mode 100644 src/lib/mail-accounts.ts create mode 100644 src/lib/mail-outbox.ts create mode 100644 src/lib/mail-recipients.ts create mode 100644 src/lib/mailer.ts diff --git a/drizzle/0019_nostalgic_warstar.sql b/drizzle/0019_nostalgic_warstar.sql new file mode 100644 index 0000000..f911e34 --- /dev/null +++ b/drizzle/0019_nostalgic_warstar.sql @@ -0,0 +1,58 @@ +CREATE TYPE "public"."mail_kind" AS ENUM('credentials', 'information', 'meeting', 'studio', 'test');--> statement-breakpoint +CREATE TYPE "public"."mail_provider" AS ENUM('google', 'microsoft', 'smtp');--> statement-breakpoint +CREATE TYPE "public"."mail_status" AS ENUM('queued', 'sending', 'sent', 'failed', 'cancelled');--> statement-breakpoint +CREATE TABLE "mail_accounts" ( + "id" serial PRIMARY KEY NOT NULL, + "provider" "mail_provider" NOT NULL, + "from_address" varchar(200), + "from_name" varchar(200), + "is_active" boolean DEFAULT false NOT NULL, + "app_id" varchar(200), + "app_secret" text, + "access_token" text, + "refresh_token" text, + "expires_at" timestamp with time zone, + "smtp_host" varchar(200), + "smtp_port" integer, + "smtp_secure" boolean DEFAULT true NOT NULL, + "smtp_user" varchar(200), + "smtp_password" text, + "connected_by_id" integer, + "connected_at" timestamp with time zone, + "last_check_at" timestamp with time zone, + "last_check_ok" boolean, + "last_check_error" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "mail_accounts_provider_unique" UNIQUE("provider") +); +--> statement-breakpoint +CREATE TABLE "mail_messages" ( + "id" serial PRIMARY KEY NOT NULL, + "kind" "mail_kind" NOT NULL, + "status" "mail_status" DEFAULT 'queued' NOT NULL, + "to_address" varchar(200) NOT NULL, + "to_name" varchar(200), + "subject" varchar(300) NOT NULL, + "html" text NOT NULL, + "text" text, + "reply_to" varchar(200), + "information_id" integer, + "meeting_id" integer, + "member_id" integer, + "created_by_id" integer, + "provider" "mail_provider", + "attempts" integer DEFAULT 0 NOT NULL, + "next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL, + "locked_at" timestamp with time zone, + "sent_at" timestamp with time zone, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "mail_accounts" ADD CONSTRAINT "mail_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_information_id_informations_id_fk" FOREIGN KEY ("information_id") REFERENCES "public"."informations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_meeting_id_meetings_id_fk" FOREIGN KEY ("meeting_id") REFERENCES "public"."meetings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "public"."members"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "mail_messages" ADD CONSTRAINT "mail_messages_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "mail_messages_due_idx" ON "mail_messages" USING btree ("status","next_attempt_at");--> statement-breakpoint +CREATE INDEX "mail_messages_information_idx" ON "mail_messages" USING btree ("information_id"); \ No newline at end of file diff --git a/drizzle/meta/0019_snapshot.json b/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000..d6611b5 --- /dev/null +++ b/drizzle/meta/0019_snapshot.json @@ -0,0 +1,2073 @@ +{ + "id": "d119f136-adc6-44a1-a4dc-a77bafa56690", + "prevId": "e80a2f6a-ea01-4d00-ab3d-7472b909ff82", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_log": { + "name": "activity_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dot": { + "name": "dot", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#2C6FB3'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "accent": { + "name": "accent", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#E0A63C'" + }, + "tint": { + "name": "tint", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'#f6efdc'" + }, + "sort": { + "name": "sort", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_messages": { + "name": "contact_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "contact_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_consents": { + "name": "image_consents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "decision": { + "name": "decision", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signatory_name": { + "name": "signatory_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "signature_png": { + "name": "signature_png", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_consents_member_id_members_id_fk": { + "name": "image_consents_member_id_members_id_fk", + "tableFrom": "image_consents", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.information_reads": { + "name": "information_reads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "information_id": { + "name": "information_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "information_reads_user_info_idx": { + "name": "information_reads_user_info_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "information_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "information_reads_information_id_informations_id_fk": { + "name": "information_reads_information_id_informations_id_fk", + "tableFrom": "information_reads", + "tableTo": "informations", + "columnsFrom": [ + "information_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "information_reads_user_id_users_id_fk": { + "name": "information_reads_user_id_users_id_fk", + "tableFrom": "information_reads", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.informations": { + "name": "informations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "info_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "email_sent_at": { + "name": "email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "informations_feed_idx": { + "name": "informations_feed_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "informations_author_id_users_id_fk": { + "name": "informations_author_id_users_id_fk", + "tableFrom": "informations", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_accounts": { + "name": "mail_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "mail_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "from_address": { + "name": "from_address", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "smtp_host": { + "name": "smtp_host", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "smtp_port": { + "name": "smtp_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "smtp_secure": { + "name": "smtp_secure", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "smtp_user": { + "name": "smtp_user", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "smtp_password": { + "name": "smtp_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_ok": { + "name": "last_check_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mail_accounts_connected_by_id_users_id_fk": { + "name": "mail_accounts_connected_by_id_users_id_fk", + "tableFrom": "mail_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mail_accounts_provider_unique": { + "name": "mail_accounts_provider_unique", + "nullsNotDistinct": false, + "columns": [ + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mail_messages": { + "name": "mail_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "mail_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "mail_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "to_address": { + "name": "to_address", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "to_name": { + "name": "to_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to": { + "name": "reply_to", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "information_id": { + "name": "information_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "mail_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mail_messages_due_idx": { + "name": "mail_messages_due_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mail_messages_information_idx": { + "name": "mail_messages_information_idx", + "columns": [ + { + "expression": "information_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mail_messages_information_id_informations_id_fk": { + "name": "mail_messages_information_id_informations_id_fk", + "tableFrom": "mail_messages", + "tableTo": "informations", + "columnsFrom": [ + "information_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mail_messages_meeting_id_meetings_id_fk": { + "name": "mail_messages_meeting_id_meetings_id_fk", + "tableFrom": "mail_messages", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mail_messages_member_id_members_id_fk": { + "name": "mail_messages_member_id_members_id_fk", + "tableFrom": "mail_messages", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mail_messages_created_by_id_users_id_fk": { + "name": "mail_messages_created_by_id_users_id_fk", + "tableFrom": "mail_messages", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meeting_registrations": { + "name": "meeting_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attendee_name": { + "name": "attendee_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "attendee_company": { + "name": "attendee_company", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "attendee_email": { + "name": "attendee_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "attendee_phone": { + "name": "attendee_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_consent": { + "name": "image_consent", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meeting_registrations_meeting_idx": { + "name": "meeting_registrations_meeting_idx", + "columns": [ + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meeting_registrations_member_meeting_idx": { + "name": "meeting_registrations_member_meeting_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "meeting_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meeting_registrations_meeting_id_meetings_id_fk": { + "name": "meeting_registrations_meeting_id_meetings_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meeting_registrations_member_id_members_id_fk": { + "name": "meeting_registrations_member_id_members_id_fk", + "tableFrom": "meeting_registrations", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meetings": { + "name": "meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "participants_per_account": { + "name": "participants_per_account", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "contact_email": { + "name": "contact_email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "contact_first_name": { + "name": "contact_first_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "contact_last_name": { + "name": "contact_last_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "contact_phone": { + "name": "contact_phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "highlighted": { + "name": "highlighted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "member_since": { + "name": "member_since", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "hours": { + "name": "hours", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_category_id_categories_id_fk": { + "name": "members_category_id_categories_id_fk", + "tableFrom": "members", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_requests": { + "name": "membership_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meeting_photos": { + "name": "past_meeting_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "past_meeting_id": { + "name": "past_meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caption": { + "name": "caption", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meeting_photos_past_meeting_id_past_meetings_id_fk": { + "name": "past_meeting_photos_past_meeting_id_past_meetings_id_fk", + "tableFrom": "past_meeting_photos", + "tableTo": "past_meetings", + "columnsFrom": [ + "past_meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.past_meetings": { + "name": "past_meetings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meeting_id": { + "name": "meeting_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "past_meetings_meeting_id_meetings_id_fk": { + "name": "past_meetings_meeting_id_meetings_id_fk", + "tableFrom": "past_meetings", + "tableTo": "meetings", + "columnsFrom": [ + "meeting_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.promotions": { + "name": "promotions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "badge": { + "name": "badge", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "promo_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "starts_on": { + "name": "starts_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_until": { + "name": "valid_until", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "share_facebook": { + "name": "share_facebook", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_linkedin": { + "name": "share_linkedin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "publish_at": { + "name": "publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "promo_suspended_by", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "suspended_by_id": { + "name": "suspended_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promotions_member_id_members_id_fk": { + "name": "promotions_member_id_members_id_fk", + "tableFrom": "promotions", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "promotions_suspended_by_id_users_id_fk": { + "name": "promotions_suspended_by_id_users_id_fk", + "tableFrom": "promotions", + "tableTo": "users", + "columnsFrom": [ + "suspended_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(120)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_accounts": { + "name": "social_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "app_secret": { + "name": "app_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_at": { + "name": "last_check_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_check_ok": { + "name": "last_check_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_check_error": { + "name": "last_check_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "social_accounts_connected_by_id_users_id_fk": { + "name": "social_accounts_connected_by_id_users_id_fk", + "tableFrom": "social_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "social_accounts_network_unique": { + "name": "social_accounts_network_unique", + "nullsNotDistinct": false, + "columns": [ + "network" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_posts": { + "name": "social_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "promotion_id": { + "name": "promotion_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "social_network", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "social_post_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_by_id": { + "name": "posted_by_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_posts_promotion_idx": { + "name": "social_posts_promotion_idx", + "columns": [ + { + "expression": "promotion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_posts_promotion_id_promotions_id_fk": { + "name": "social_posts_promotion_id_promotions_id_fk", + "tableFrom": "social_posts", + "tableTo": "promotions", + "columnsFrom": [ + "promotion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "social_posts_posted_by_id_users_id_fk": { + "name": "social_posts_posted_by_id_users_id_fk", + "tableFrom": "social_posts", + "tableTo": "users", + "columnsFrom": [ + "posted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "member_id": { + "name": "member_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "session_version": { + "name": "session_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_member_id_members_id_fk": { + "name": "users_member_id_members_id_fk", + "tableFrom": "users", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.contact_status": { + "name": "contact_status", + "schema": "public", + "values": [ + "new", + "read", + "archived" + ] + }, + "public.info_status": { + "name": "info_status", + "schema": "public", + "values": [ + "draft", + "published" + ] + }, + "public.mail_kind": { + "name": "mail_kind", + "schema": "public", + "values": [ + "credentials", + "information", + "meeting", + "studio", + "test" + ] + }, + "public.mail_provider": { + "name": "mail_provider", + "schema": "public", + "values": [ + "google", + "microsoft", + "smtp" + ] + }, + "public.mail_status": { + "name": "mail_status", + "schema": "public", + "values": [ + "queued", + "sending", + "sent", + "failed", + "cancelled" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "pending" + ] + }, + "public.promo_status": { + "name": "promo_status", + "schema": "public", + "values": [ + "pending", + "live", + "expired", + "rejected", + "suspended", + "scheduled" + ] + }, + "public.promo_suspended_by": { + "name": "promo_suspended_by", + "schema": "public", + "values": [ + "member", + "staff" + ] + }, + "public.request_status": { + "name": "request_status", + "schema": "public", + "values": [ + "new", + "approved", + "rejected" + ] + }, + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "admin", + "moderator", + "editor", + "member" + ] + }, + "public.social_network": { + "name": "social_network", + "schema": "public", + "values": [ + "facebook", + "linkedin" + ] + }, + "public.social_post_status": { + "name": "social_post_status", + "schema": "public", + "values": [ + "posted", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 86e7adc..d7d3d15 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1789399747132, "tag": "0018_lazy_big_bertha", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1789401051538, + "tag": "0019_nostalgic_warstar", + "breakpoints": true } ] } \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 0f9da3d..43b64d2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -4,6 +4,9 @@ const nextConfig = { reactStrictMode: true, // Pas d'en-tête X-Powered-By : inutile de renseigner un attaquant sur la pile. poweredByHeader: false, + // `nodemailer` charge net/tls/dns par des requires dynamiques : laissé au + // bundler, il se retrouve à moitié inliné et casse à l'exécution. + serverExternalPackages: ["nodemailer"], experimental: { // Les images d'entête/logo sont envoyées en data-URL via server action. serverActions: { bodySizeLimit: "4mb" }, diff --git a/package-lock.json b/package-lock.json index 8fa3a9e..6a3f00c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "drizzle-orm": "^0.45.2", "next": "^15.5.25", "next-auth": "^5.0.0-beta.32", + "nodemailer": "^8.0.11", "pg": "^8.23.0", "react": "19.0.0", "react-dom": "19.0.0", @@ -21,6 +22,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.5", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.11.10", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", @@ -1220,6 +1222,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -1711,6 +1723,15 @@ } } }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/oauth4webapi": { "version": "3.8.6", "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", diff --git a/package.json b/package.json index d9056ea..4fe992a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "drizzle-orm": "^0.45.2", "next": "^15.5.25", "next-auth": "^5.0.0-beta.32", + "nodemailer": "^8.0.11", "pg": "^8.23.0", "react": "19.0.0", "react-dom": "19.0.0", @@ -28,6 +29,7 @@ "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/node": "^22.10.5", + "@types/nodemailer": "^8.0.1", "@types/pg": "^8.11.10", "@types/react": "^19.0.7", "@types/react-dom": "^19.0.3", diff --git a/src/app/api/mail/[provider]/callback/route.ts b/src/app/api/mail/[provider]/callback/route.ts new file mode 100644 index 0000000..9561e49 --- /dev/null +++ b/src/app/api/mail/[provider]/callback/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { can } from "@/lib/rbac"; +import { + exchangeMailCode, + getActiveMailAccount, + getDecryptedAppSecret, + getMailAccount, + saveMailConnection, + setActiveMailProvider, + MAIL_PROVIDERS, +} from "@/lib/mail-accounts"; +import { publicBaseUrl } from "@/lib/social-accounts"; +import type { MailProvider } from "@/db/schema"; + +export const dynamic = "force-dynamic"; + +const SETTINGS = "/backend/boite-mail"; + +function back(request: Request, params: Record) { + const url = new URL(SETTINGS, request.url); + for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value); + const response = NextResponse.redirect(url); + // Le state a fait son office, quel que soit le résultat. + for (const provider of MAIL_PROVIDERS) response.cookies.delete(`plr_mail_oauth_${provider}`); + return response; +} + +export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { + const session = await getSession(); + if (!can(session?.user.role, "manageSettings")) { + return NextResponse.redirect(new URL("/backend", request.url)); + } + + const { provider: raw } = await params; + const provider = raw as MailProvider; + if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") { + return back(request, { error: "Fournisseur inconnu." }); + } + + const url = new URL(request.url); + const failure = url.searchParams.get("error_description") ?? url.searchParams.get("error"); + if (failure) return back(request, { error: `Autorisation refusée : ${failure}` }); + + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const expected = request.headers + .get("cookie") + ?.split(";") + .map((cookie) => cookie.trim()) + .find((cookie) => cookie.startsWith(`plr_mail_oauth_${provider}=`)) + ?.split("=")[1]; + + if (!code || !state || !expected || state !== expected) { + return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." }); + } + + const account = await getMailAccount(provider); + const appSecret = await getDecryptedAppSecret(provider); + if (!account?.appId || !appSecret) { + return back(request, { error: "Identifiants d'application introuvables." }); + } + + const base = await publicBaseUrl(); + if (!base) return back(request, { error: "Adresse publique du site introuvable." }); + + try { + const result = await exchangeMailCode(provider, account.appId, appSecret, code, base); + const userId = Number(session?.user.id); + await saveMailConnection({ + provider, + ...result, + connectedById: Number.isFinite(userId) ? userId : null, + }); + // Première boîte connectée : elle devient l'expéditeur, sinon l'écran + // afficherait une connexion réussie sans qu'aucun message ne parte. Si un + // autre fournisseur expédie déjà, on ne le détrône pas dans son dos. + if (!(await getActiveMailAccount())) await setActiveMailProvider(provider); + return back(request, { connected: provider }); + } catch (caught) { + const message = caught instanceof Error ? caught.message : "Échec de la connexion."; + return back(request, { error: message.slice(0, 400) }); + } +} diff --git a/src/app/api/mail/[provider]/connect/route.ts b/src/app/api/mail/[provider]/connect/route.ts new file mode 100644 index 0000000..d52e2cb --- /dev/null +++ b/src/app/api/mail/[provider]/connect/route.ts @@ -0,0 +1,62 @@ +import { randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/session"; +import { can } from "@/lib/rbac"; +import { + getDecryptedAppSecret, + getMailAccount, + mailAuthorizeUrl, + MAIL_PROVIDERS, +} from "@/lib/mail-accounts"; +import { publicBaseUrl } from "@/lib/social-accounts"; +import type { MailProvider } from "@/db/schema"; + +export const dynamic = "force-dynamic"; + +const SETTINGS = "/backend/boite-mail"; + +function back(request: Request, error: string) { + return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url)); +} + +export async function GET(request: Request, { params }: { params: Promise<{ provider: string }> }) { + const session = await getSession(); + if (!can(session?.user.role, "manageSettings")) { + return NextResponse.redirect(new URL("/backend", request.url)); + } + + const { provider: raw } = await params; + const provider = raw as MailProvider; + if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") { + return back(request, "Fournisseur inconnu."); + } + + // Réglage enregistré, sinon l'adresse par laquelle l'administrateur est + // arrivé : c'est celle où le fournisseur nous renverra. + const base = await publicBaseUrl(); + if (!base) { + return back( + request, + "Impossible de déterminer l'adresse publique du site : renseignez-la dans Backend › Réseaux sociaux." + ); + } + + const account = await getMailAccount(provider); + const appSecret = await getDecryptedAppSecret(provider); + if (!account?.appId || !appSecret) { + return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application."); + } + + // `state` en cookie httpOnly : vérifié au retour pour écarter toute requête + // de rappel forgée. Aucun jeton ne transite jamais par une URL. + const state = randomBytes(24).toString("hex"); + const response = NextResponse.redirect(await mailAuthorizeUrl(provider, account.appId, state, base)); + response.cookies.set(`plr_mail_oauth_${provider}`, state, { + httpOnly: true, + sameSite: "lax", + secure: new URL(base).protocol === "https:", + path: "/", + maxAge: 600, + }); + return response; +} diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index 324abba..a27a5e4 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -22,6 +22,7 @@ const TITLES: Record = { "/backend/informations": ["Informations", "Ce que l'association publie dans l'espace adhérent"], "/backend/emails": ["Création d'e-mails", "Composer des messages aux couleurs de Plein R"], "/backend/reseaux": ["Réseaux sociaux", "Connecter les pages Facebook et LinkedIn"], + "/backend/boite-mail": ["Boîte mail", "Brancher la boîte de l'association pour les envois du site"], "/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"], "/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"], "/backend/parametres": ["Paramètres du site", "Configurer l'association et les mentions légales"], @@ -83,6 +84,7 @@ const SECTIONS: NavSection[] = [ label: "Configuration", items: [ { href: "/backend/categories", label: "Catégories", icon: "categories", capability: "manageCategories" }, + { href: "/backend/boite-mail", label: "Boîte mail", icon: "mailbox", capability: "manageSettings" }, { href: "/backend/parametres", label: "Paramètres du site", icon: "settings", capability: "manageSettings" }, { href: "/backend/administrateurs", label: "Administrateurs", icon: "admins", capability: "manageAdmins" }, ], diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 0b9b262..9840bd6 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -43,6 +43,22 @@ import { isRangeInvalid } from "@/lib/promo-validity"; import { formatSchedule, isDue, parseScheduleInput } from "@/lib/promo-schedule"; import { publishPromoShares, requestedNetworks } from "@/lib/promo-publish"; import { logActivity } from "@/lib/activity-log"; +import { + MAIL_LABELS, + MAIL_PROVIDERS, + checkMailHealth, + disconnectMailAccount, + saveOAuthApp, + saveSmtpAccount, + setActiveMailProvider, +} from "@/lib/mail-accounts"; +import type { MailProvider } from "@/db/schema"; +import { sendNow } from "@/lib/mailer"; +import { cancelMailMessage, logSentMail, retryMailMessage } from "@/lib/mail-outbox"; +import { selfRecipient } from "@/lib/mail-recipients"; +import { buildGeneralEmail } from "@/lib/email-templates"; +import { emailBrand, getSiteSettings } from "@/lib/site-settings"; +import { siteUrl } from "@/lib/social-accounts"; import { markInformationsRead as markRead } from "@/lib/informations"; import { SITE_SETTING_DEFAULTS } from "@/lib/site-settings"; import type { AppRole } from "@/types/next-auth"; @@ -1471,3 +1487,144 @@ export async function markInformationsRead(ids: number[]) { revalidatePath("/backend/espace/promotions"); revalidatePath("/backend"); } + +// ---- Boîte mail de l'association ---- + +async function requireMailSettings() { + const access = await requireRole(); + if (!can(access.role, "manageSettings")) throw new Error("Accès refusé"); + return access; +} + +function mailRedirect(message: string, tone: "error" | "ok") { + redirect(`/backend/boite-mail?${tone}=${encodeURIComponent(message)}`); +} + +export async function saveMailApp(formData: FormData) { + await requireMailSettings(); + const provider = String(formData.get("provider")) as MailProvider; + if (!MAIL_PROVIDERS.includes(provider) || provider === "smtp") return; + + const appId = asString(formData, "appId"); + if (!appId) mailRedirect("L'identifiant de l'application est requis.", "error"); + // Champ secret laissé vide = on garde celui déjà enregistré. + const appSecret = asString(formData, "appSecret") || null; + try { + await saveOAuthApp(provider, appId, appSecret); + } catch (error) { + mailRedirect(error instanceof Error ? error.message : "Enregistrement impossible.", "error"); + } + revalidatePath("/backend/boite-mail"); +} + +export async function saveMailSmtp(formData: FormData) { + await requireMailSettings(); + const host = asString(formData, "smtpHost"); + const user = asString(formData, "smtpUser"); + const port = Number(formData.get("smtpPort") ?? 465) || 465; + if (!host || !user) mailRedirect("Le serveur et l'identifiant sont requis.", "error"); + + try { + await saveSmtpAccount({ + host, + port: Math.min(65535, Math.max(1, port)), + secure: formData.get("smtpSecure") === "on", + user, + password: asString(formData, "smtpPassword") || null, + fromAddress: asString(formData, "fromAddress") || user, + fromName: asString(formData, "fromName"), + }); + } catch (error) { + mailRedirect(error instanceof Error ? error.message : "Enregistrement impossible.", "error"); + } + revalidatePath("/backend/boite-mail"); + mailRedirect("Réglages SMTP enregistrés.", "ok"); +} + +export async function setMailProvider(formData: FormData) { + await requireMailSettings(); + const provider = String(formData.get("provider")) as MailProvider; + if (!MAIL_PROVIDERS.includes(provider)) return; + await setActiveMailProvider(provider); + await logActivity(`Boîte mail : expédition via ${MAIL_LABELS[provider]}`, "#2C6FB3"); + revalidatePath("/backend/boite-mail"); +} + +export async function disconnectMail(formData: FormData) { + await requireMailSettings(); + const provider = String(formData.get("provider")) as MailProvider; + if (!MAIL_PROVIDERS.includes(provider)) return; + await disconnectMailAccount(provider); + await logActivity(`Boîte mail déconnectée : ${MAIL_LABELS[provider]}`, "#d8472b"); + revalidatePath("/backend/boite-mail"); +} + +/** + * Envoi de contrôle vers sa propre adresse. Direct et non mis en file : c'est + * précisément le verdict immédiat qu'on cherche. + */ +export async function sendMailTest() { + const { userId, name } = await requireMailSettings(); + const recipient = userId ? await selfRecipient(userId) : null; + if (!recipient) mailRedirect("Votre compte n'a pas d'adresse e-mail utilisable.", "error"); + + const settings = await getSiteSettings(); + const base = await siteUrl(); + const { subject, html } = buildGeneralEmail( + { + subject: `Test d'envoi — ${settings.association_name}`, + kicker: settings.association_name, + title: "La boîte mail répond", + body: `Bonjour ${name},\n\nCe message confirme que le site sait expédier depuis la boîte de l'association.\n\nSi vous le recevez, les mots de passe temporaires, les invitations aux rencontres et les informations diffusées partiront de la même façon.`, + buttonLabel: "", + buttonUrl: "", + signature: `L'équipe de ${settings.association_name}`, + }, + base, + emailBrand(settings) + ); + + const result = await sendNow({ + to: recipient!.email, + toName: recipient!.name, + subject, + html, + replyTo: settings.association_email || null, + }); + await logSentMail({ + kind: "test", + toAddress: recipient!.email, + subject, + createdById: userId, + result: result.ok ? { ok: true } : { ok: false, reason: result.reason }, + }); + + revalidatePath("/backend/boite-mail"); + mailRedirect( + result.ok ? `Message de test envoyé à ${recipient!.email}.` : `Échec de l'envoi : ${result.reason}`, + result.ok ? "ok" : "error" + ); +} + +/** Contrôle la santé des trois fournisseurs à la demande. */ +export async function checkMailProvider(formData: FormData) { + await requireMailSettings(); + const provider = String(formData.get("provider")) as MailProvider; + if (!MAIL_PROVIDERS.includes(provider)) return; + await checkMailHealth(provider); + revalidatePath("/backend/boite-mail"); +} + +export async function retryMail(formData: FormData) { + await requireMailSettings(); + const id = Number(formData.get("id") ?? 0); + if (id) await retryMailMessage(id); + revalidatePath("/backend/boite-mail"); +} + +export async function cancelMail(formData: FormData) { + await requireMailSettings(); + const id = Number(formData.get("id") ?? 0); + if (id) await cancelMailMessage(id); + revalidatePath("/backend/boite-mail"); +} diff --git a/src/app/backend/boite-mail/page.tsx b/src/app/backend/boite-mail/page.tsx new file mode 100644 index 0000000..ee57739 --- /dev/null +++ b/src/app/backend/boite-mail/page.tsx @@ -0,0 +1,399 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import type { CSSProperties, ReactNode } from "react"; +import { getSession } from "@/lib/session"; +import { can } from "@/lib/rbac"; +import { + MAIL_LABELS, + MAIL_PROVIDERS, + getMailAccounts, + mailHealthCached, + type MailHealth, +} from "@/lib/mail-accounts"; +import { recentMails } from "@/lib/mail-outbox"; +import { publicBaseUrl } from "@/lib/social-accounts"; +import { getSiteSettings } from "@/lib/site-settings"; +import type { MailAccount, MailProvider } from "@/db/schema"; +import { + cancelMail, + checkMailProvider, + disconnectMail, + retryMail, + saveMailApp, + saveMailSmtp, + sendMailTest, + setMailProvider, +} from "../actions"; + +export const dynamic = "force-dynamic"; + +const panel: CSSProperties = { background: "#fff", border: "1px solid #e6dcc6", borderRadius: 14, padding: "18px 20px", marginBottom: 16 }; +const eyebrow: CSSProperties = { fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 12 }; +const ghost: CSSProperties = { border: "1px solid #d8cdb4", background: "#fff", color: "#6c6150", fontWeight: 700, fontSize: 12.5, padding: "8px 13px", borderRadius: 9, cursor: "pointer", textDecoration: "none", display: "inline-block" }; +const primary: CSSProperties = { ...ghost, border: "none", background: "#13324F", color: "#fff", fontWeight: 800 }; +const danger: CSSProperties = { ...ghost, color: "#d8472b", borderColor: "#e0c3bb" }; +const pill: CSSProperties = { borderRadius: 999, padding: "5px 11px", fontSize: 11, fontWeight: 800, whiteSpace: "nowrap" }; + +const BRAND: Record = { + google: { color: "#1a73e8", mark: "G" }, + microsoft: { color: "#0f6cbd", mark: "M" }, + smtp: { color: "#6c6150", mark: "@" }, +}; + +const HELP: Record<"google" | "microsoft", { portal: string; steps: string[]; caution: string }> = { + google: { + portal: "https://console.cloud.google.com/apis/credentials", + steps: [ + "Créez un projet, puis un identifiant OAuth de type « Application Web ».", + "Activez l'API Gmail dans la bibliothèque d'API du projet.", + "Sur l'écran de consentement, ajoutez la portée .../auth/gmail.send.", + "Déclarez l'adresse de retour ci-dessous comme URI de redirection autorisé.", + "Collez l'ID client et le code secret du client ci-dessus, puis connectez-vous avec la boîte de l'association.", + ], + caution: + "Tant que l'application reste en mode « Test », Google fait expirer l'autorisation au bout de 7 jours et l'envoi s'arrête sans prévenir. Passez-la « En production » (un écran d'avertissement subsiste, sans conséquence), ou déclarez-la « Interne » si l'association a un compte Google Workspace.", + }, + microsoft: { + portal: "https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade", + steps: [ + "Inscrivez une application, en autorisant « comptes dans un annuaire quelconque et comptes Microsoft personnels ».", + "Ajoutez l'adresse de retour ci-dessous comme URI de redirection de type « Web ».", + "Dans les autorisations d'API, ajoutez les permissions déléguées Mail.Send, User.Read et offline_access.", + "Créez un secret client et notez sa valeur : elle n'est affichée qu'une fois.", + "Collez l'ID d'application et le secret ci-dessus, puis connectez-vous avec la boîte de l'association.", + ], + caution: + "Microsoft a désactivé l'authentification par simple mot de passe (SMTP AUTH) sur les boîtes outlook.com et hotmail.com : cette connexion par bouton est le seul chemin fiable pour ces adresses.", + }, +}; + +function Banner({ tone, children }: { tone: "error" | "ok" | "warn"; children: ReactNode }) { + const palette = { + error: { background: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" }, + ok: { background: "#e6f4ec", border: "#c4e2d1", color: "#1f8a5b" }, + warn: { background: "#fbeede", border: "#ecd8b8", color: "#9a6638" }, + }[tone]; + return ( +
+ {children} +
+ ); +} + +function StatusChip({ account, health }: { account: MailAccount | undefined; health: MailHealth | null }) { + if (!account) return Non configurée; + if (health && !health.ok) return Ne répond plus; + if (account.provider !== "smtp" && !account.fromAddress) { + return Non connectée; + } + if (account.provider === "smtp" && !account.smtpHost) { + return Non configurée; + } + return Connectée; +} + +function formatMoment(value: Date) { + return new Date(value).toLocaleString("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); +} + +const MAIL_STATUS: Record = { + queued: { label: "En file", background: "#eaf0f6", color: "#2C6FB3" }, + sending: { label: "En cours", background: "#eaf0f6", color: "#2C6FB3" }, + sent: { label: "Remis", background: "#e6f4ec", color: "#1f8a5b" }, + failed: { label: "Échec", background: "#fbe9e6", color: "#d8472b" }, + cancelled: { label: "Annulé", background: "#f1efe7", color: "#a99c82" }, +}; + +/** + * Boîte mail de l'association. + * + * Même structure que Backend › Réseaux sociaux, et mêmes règles : aucun secret + * ne descend vers le navigateur — le champ mot de passe part vide et un champ + * vide conserve la valeur enregistrée. + */ +export default async function BoiteMailPage({ + searchParams, +}: { + searchParams: Promise<{ error?: string; ok?: string; connected?: string }>; +}) { + const session = await getSession(); + if (!can(session?.user.role, "manageSettings")) redirect("/backend"); + + const { error, ok, connected } = await searchParams; + const [accounts, settings, base, log] = await Promise.all([ + getMailAccounts(), + getSiteSettings(), + publicBaseUrl(), + recentMails(20), + ]); + const byProvider = new Map(accounts.map((account) => [account.provider, account])); + const health = Object.fromEntries( + await Promise.all(MAIL_PROVIDERS.map(async (provider) => [provider, await mailHealthCached(provider)] as const)), + ) as Record; + const active = accounts.find((account) => account.isActive) ?? null; + + return ( +
+ {error && {error}} + {ok && {ok}} + {connected && Boîte {MAIL_LABELS[connected as MailProvider] ?? connected} connectée.} + {!active && ( + + Aucune boîte n'expédie pour l'instant : les mots de passe temporaires restent affichés à + l'écran, et aucune information ne part par e-mail. + + )} + + {MAIL_PROVIDERS.map((provider) => { + const account = byProvider.get(provider); + const verdict = health[provider]; + const brand = BRAND[provider]; + const isActive = account?.isActive ?? false; + const connectable = provider !== "smtp"; + const usable = provider === "smtp" ? Boolean(account?.smtpHost) : Boolean(account?.fromAddress); + + return ( +
+
+
+ +
+
{MAIL_LABELS[provider]}
+
+ {provider === "smtp" + ? account?.smtpHost + ? `${account.smtpHost} : ${account.smtpPort ?? 465} · ${account.smtpUser}` + : "Aucun serveur enregistré" + : account?.fromAddress + ? `${account.fromAddress}${account.connectedAt ? ` · connectée le ${formatMoment(account.connectedAt)}` : ""}` + : "Aucun compte relié"} +
+
+
+
+ + {usable && ( +
+ + +
+ )} +
+
+ + {verdict && !verdict.ok && ( +
+ {verdict.reason.slice(0, 220)} +
+ )} + +
+ + {provider === "smtp" ? ( +
+
+ + + +
+
+ + + +
+ +

+ L'adresse d'expédition doit appartenir au domaine qui authentifie la connexion, sinon + SPF et DKIM échouent et les messages partent en indésirable. +

+
+ +
+
+ ) : ( + <> +
+ +
+ + +
+
+ + + {account?.fromAddress ? "Reconnecter la boîte" : "Connecter la boîte"} + +
+
+ +
+ + Comment obtenir ces identifiants ? + +
    + {HELP[provider].steps.map((step) => ( +
  1. {step}
  2. + ))} +
+

+ Adresse de retour à déclarer :{" "} + + {base ? `${base}/api/mail/${provider}/callback` : "définissez d'abord l'URL publique du site"} + +

+

+ + Ouvrir la console {provider === "google" ? "Google Cloud" : "Microsoft Entra"} ↗ + +

+
+ {HELP[provider].caution} +
+
+ + )} + + {usable && ( +
+
+ + +
+ {connectable && account?.fromAddress && ( +
+ + +
+ )} + {verdict?.ok && ✓ {verdict.detail}} +
+ )} +
+ ); + })} + +
+
Envoi de contrôle
+
+ + + Part immédiatement vers votre propre adresse et affiche le verdict, sans passer par la file. + +
+
+ +
+
Journal d'envoi
+ {log.length === 0 ? ( +
Aucun message expédié pour l'instant.
+ ) : ( +
+ + + + {["Quand", "Destinataire", "Objet", "État", ""].map((header) => ( + + ))} + + + + {log.map((message) => { + const status = MAIL_STATUS[message.status] ?? MAIL_STATUS.queued; + return ( + + + + + + + + ); + })} + +
+ {header} +
+ {formatMoment(message.createdAt)} + {message.toAddress} + {message.subject} + {message.error &&
{message.error.slice(0, 160)}
} +
+ {status.label} + + {message.status === "failed" && ( +
+ + +
+ )} + {message.status === "queued" && ( +
+ + +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/src/app/backend/emails/page.tsx b/src/app/backend/emails/page.tsx index 0b295e6..2f4f9e2 100644 --- a/src/app/backend/emails/page.tsx +++ b/src/app/backend/emails/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { getSession } from "@/lib/session"; import { EmailCreator } from "@/components/EmailCreator"; import { can } from "@/lib/rbac"; -import { getSiteSettings } from "@/lib/site-settings"; +import { emailBrand, getSiteSettings } from "@/lib/site-settings"; export const dynamic = "force-dynamic"; @@ -10,13 +10,6 @@ export default async function EmailsPage() { const session = await getSession(); if (!can(session?.user.role, "manageEmails")) redirect("/backend"); const settings = await getSiteSettings(); - const brand = { - associationName: settings.association_name, - address: settings.association_address, - email: settings.association_email, - phone: settings.association_phone, - siret: settings.association_siret, - }; - return ; + return ; } diff --git a/src/db/schema.ts b/src/db/schema.ts index f0ccca7..a84866d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -43,6 +43,21 @@ export const contactStatusEnum = pgEnum("contact_status", [ "archived", ]); export const infoStatusEnum = pgEnum("info_status", ["draft", "published"]); +export const mailProviderEnum = pgEnum("mail_provider", ["google", "microsoft", "smtp"]); +export const mailStatusEnum = pgEnum("mail_status", [ + "queued", + "sending", + "sent", + "failed", + "cancelled", +]); +export const mailKindEnum = pgEnum("mail_kind", [ + "credentials", + "information", + "meeting", + "studio", + "test", +]); // ---- Categories (métiers) ---- export const categories = pgTable("categories", { @@ -338,6 +353,69 @@ export const informationReads = pgTable( }) ); +// ---- Boîte mail de l'association ---- +// Une ligne par fournisseur, `is_active` désigne celui qui expédie. Les secrets +// sont chiffrés (`src/lib/crypto.ts`) et ne ressortent jamais vers le +// navigateur — même règle que `social_accounts`. +export const mailAccounts = pgTable("mail_accounts", { + id: serial("id").primaryKey(), + provider: mailProviderEnum("provider").notNull().unique(), + // Lue chez le fournisseur pour Google et Microsoft : on expédie comme la + // boîte authentifiée, sinon SPF et DKIM tombent. + fromAddress: varchar("from_address", { length: 200 }), + fromName: varchar("from_name", { length: 200 }), + isActive: boolean("is_active").notNull().default(false), + appId: varchar("app_id", { length: 200 }), + appSecret: text("app_secret"), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + expiresAt: timestamp("expires_at", { withTimezone: true }), + smtpHost: varchar("smtp_host", { length: 200 }), + smtpPort: integer("smtp_port"), + smtpSecure: boolean("smtp_secure").notNull().default(true), + smtpUser: varchar("smtp_user", { length: 200 }), + smtpPassword: text("smtp_password"), + connectedById: integer("connected_by_id").references(() => users.id, { onDelete: "set null" }), + connectedAt: timestamp("connected_at", { withTimezone: true }), + lastCheckAt: timestamp("last_check_at", { withTimezone: true }), + lastCheckOk: boolean("last_check_ok"), + lastCheckError: text("last_check_error"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +// File d'attente d'envoi. `to_address` est un destinataire **unique** : une +// diffusion produit autant de lignes que d'adhérents, et aucune adresse ne peut +// donc apparaître aux yeux des autres. +export const mailMessages = pgTable( + "mail_messages", + { + id: serial("id").primaryKey(), + kind: mailKindEnum("kind").notNull(), + status: mailStatusEnum("status").notNull().default("queued"), + toAddress: varchar("to_address", { length: 200 }).notNull(), + toName: varchar("to_name", { length: 200 }), + subject: varchar("subject", { length: 300 }).notNull(), + html: text("html").notNull(), + text: text("text"), + replyTo: varchar("reply_to", { length: 200 }), + informationId: integer("information_id").references(() => informations.id, { onDelete: "set null" }), + meetingId: integer("meeting_id").references(() => meetings.id, { onDelete: "set null" }), + memberId: integer("member_id").references(() => members.id, { onDelete: "set null" }), + createdById: integer("created_by_id").references(() => users.id, { onDelete: "set null" }), + provider: mailProviderEnum("provider"), + attempts: integer("attempts").notNull().default(0), + nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull().defaultNow(), + lockedAt: timestamp("locked_at", { withTimezone: true }), + sentAt: timestamp("sent_at", { withTimezone: true }), + error: text("error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + dueIdx: index("mail_messages_due_idx").on(table.status, table.nextAttemptAt), + informationIdx: index("mail_messages_information_idx").on(table.informationId), + }) +); + // ---- Relations ---- export const membersRelations = relations(members, ({ one, many }) => ({ category: one(categories, { @@ -440,4 +518,8 @@ export type PastMeeting = typeof pastMeetings.$inferSelect; export type PastMeetingPhoto = typeof pastMeetingPhotos.$inferSelect; export type ImageConsent = typeof imageConsents.$inferSelect; export type Information = typeof informations.$inferSelect; +export type MailAccount = typeof mailAccounts.$inferSelect; +export type MailProvider = (typeof mailProviderEnum.enumValues)[number]; +export type MailMessage = typeof mailMessages.$inferSelect; +export type MailKind = (typeof mailKindEnum.enumValues)[number]; export type InformationRead = typeof informationReads.$inferSelect; diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 3f41306..129ec8a 100644 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -1,13 +1,19 @@ /** - * Boucle de libération des publications programmées. Fichier séparé de - * `instrumentation.ts` : il touche la base et ne doit donc jamais entrer dans - * le bundle edge du middleware. + * Boucles de fond : libération des publications programmées et vidage de la + * file d'envoi. Fichier séparé de `instrumentation.ts` : il touche la base et + * `nodemailer`, et ne doit donc jamais entrer dans le bundle edge du + * middleware — c'est cet import-là que l'`IgnorePlugin` de `next.config.mjs` + * coupe. */ import { releaseDuePromotions } from "@/lib/promo-publish"; +import { processOutbox } from "@/lib/mail-outbox"; -const INTERVAL_MS = 60_000; +const PROMO_INTERVAL_MS = 60_000; +// Plus court que la minute des promotions : un mot de passe ou une invitation +// mis en file ne doit pas attendre une minute pleine. +const MAIL_INTERVAL_MS = 20_000; -async function tick() { +async function promoTick() { try { const n = await releaseDuePromotions(); if (n > 0) console.log(`[promotions] ${n} publication(s) programmée(s) mise(s) en ligne`); @@ -18,7 +24,21 @@ async function tick() { } } +async function mailTick() { + try { + const { sent, failed } = await processOutbox(); + if (sent || failed) console.log(`[mail] ${sent} envoyé(s), ${failed} en échec`); + } catch (error) { + console.error("[mail] file d'attente :", error); + } +} + // Un premier passage au démarrage rattrape les échéances tombées pendant un // redéploiement ou une coupure. -void tick(); -setInterval(tick, INTERVAL_MS).unref(); +void promoTick(); +setInterval(promoTick, PROMO_INTERVAL_MS).unref(); + +if (process.env.MAIL_WORKER !== "off") { + void mailTick(); + setInterval(mailTick, MAIL_INTERVAL_MS).unref(); +} diff --git a/src/lib/mail-accounts.ts b/src/lib/mail-accounts.ts new file mode 100644 index 0000000..e0ece89 --- /dev/null +++ b/src/lib/mail-accounts.ts @@ -0,0 +1,532 @@ +import { eq, ne } from "drizzle-orm"; +import { db } from "@/db"; +import { mailAccounts } from "@/db/schema"; +import type { MailAccount, MailProvider } from "@/db/schema"; +import { decryptSecret, encryptSecret } from "@/lib/crypto"; + +/** + * Boîte mail de l'association : configuration, connexion, santé. + * + * Décalque volontaire de `src/lib/social-accounts.ts` — mêmes règles, mêmes + * garanties : les secrets sont chiffrés en base, ne repartent jamais vers le + * navigateur, un champ laissé vide conserve celui déjà enregistré, et le + * contrôle de santé ne lève jamais. + * + * Trois transports, un seul actif à la fois. Pas de cascade automatique : si + * Google se bloque, c'est l'administrateur qui bascule, et il le voit. + */ + +export const MAIL_PROVIDERS: MailProvider[] = ["google", "microsoft", "smtp"]; + +export const MAIL_LABELS: Record = { + google: "Google — Gmail / Workspace", + microsoft: "Microsoft — Outlook / Hotmail / 365", + smtp: "Autre serveur (SMTP)", +}; + +/** Le jeton d'accès est renouvelé un peu avant l'échéance annoncée. */ +const REFRESH_MARGIN_MS = 5 * 60_000; + +function env(key: string): string { + return (process.env[key] ?? "").trim(); +} + +// ---- Lecture ---- + +export async function getMailAccount(provider: MailProvider): Promise { + const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.provider, provider)); + return row ?? null; +} + +export async function getMailAccounts(): Promise { + return db.select().from(mailAccounts); +} + +export async function getActiveMailAccount(): Promise { + const [row] = await db.select().from(mailAccounts).where(eq(mailAccounts.isActive, true)); + return row ?? null; +} + +export type MailSender = { + provider: MailProvider; + fromAddress: string; + fromName: string; + /** Google / Microsoft : jeton valide. SMTP : chaîne vide. */ + accessToken: string; + smtp?: { host: string; port: number; secure: boolean; user: string; password: string }; +}; + +/** + * Le transport prêt à expédier, ou `null` si rien n'est configuré. + * + * Aucun repli sur l'environnement pour les jetons OAuth — ils ne peuvent + * venir que du parcours de connexion — mais les réglages SMTP restent lisibles + * depuis l'environnement, ce qui permet un premier déploiement sans passer par + * l'écran. + */ +export async function resolveMailSender(): Promise { + const account = await getActiveMailAccount(); + + if (account?.provider === "smtp") { + const password = safeDecrypt(account.smtpPassword); + if (account.smtpHost && account.smtpUser && password) { + return { + provider: "smtp", + fromAddress: account.fromAddress || account.smtpUser, + fromName: account.fromName ?? "", + accessToken: "", + smtp: { + host: account.smtpHost, + port: account.smtpPort ?? 465, + secure: account.smtpSecure, + user: account.smtpUser, + password, + }, + }; + } + } + + if (account && (account.provider === "google" || account.provider === "microsoft")) { + const accessToken = await ensureAccessToken(account.provider); + if (accessToken && account.fromAddress) { + return { + provider: account.provider, + fromAddress: account.fromAddress, + fromName: account.fromName ?? "", + accessToken, + }; + } + } + + // Repli d'environnement : utile avant toute configuration depuis l'écran. + const host = env("SMTP_HOST"); + const user = env("SMTP_USER"); + const password = env("SMTP_PASSWORD"); + if (host && user && password) { + return { + provider: "smtp", + fromAddress: env("SMTP_FROM") || user, + fromName: env("SMTP_FROM_NAME"), + accessToken: "", + smtp: { host, port: Number(env("SMTP_PORT") || 465), secure: env("SMTP_SECURE") !== "false", user, password }, + }; + } + + return null; +} + +export async function isMailConfigured(): Promise { + return (await resolveMailSender()) !== null; +} + +function safeDecrypt(value: string | null): string | null { + if (!value) return null; + try { + return decryptSecret(value); + } catch { + // Clé de chiffrement changée : on préfère « non configuré » à une erreur. + return null; + } +} + +// ---- Écriture ---- + +export async function saveOAuthApp(provider: MailProvider, appId: string, appSecret: string | null) { + const existing = await getMailAccount(provider); + if (!existing) { + if (!appSecret) throw new Error("Le secret de l'application est requis à la première saisie."); + await db.insert(mailAccounts).values({ provider, appId, appSecret: encryptSecret(appSecret), updatedAt: new Date() }); + return; + } + await db + .update(mailAccounts) + .set({ + appId, + // Champ laissé vide = on conserve le secret déjà enregistré. + ...(appSecret ? { appSecret: encryptSecret(appSecret) } : {}), + updatedAt: new Date(), + }) + .where(eq(mailAccounts.provider, provider)); +} + +export type SmtpSettings = { + host: string; + port: number; + secure: boolean; + user: string; + password: string | null; + fromAddress: string; + fromName: string; +}; + +export async function saveSmtpAccount(settings: SmtpSettings) { + const existing = await getMailAccount("smtp"); + const shared = { + smtpHost: settings.host, + smtpPort: settings.port, + smtpSecure: settings.secure, + smtpUser: settings.user, + fromAddress: settings.fromAddress || settings.user, + fromName: settings.fromName, + updatedAt: new Date(), + // Les réglages changent : le dernier verdict ne vaut plus rien. + lastCheckAt: null, + lastCheckOk: null, + lastCheckError: null, + }; + + if (!existing) { + if (!settings.password) throw new Error("Le mot de passe est requis à la première saisie."); + await db.insert(mailAccounts).values({ provider: "smtp", ...shared, smtpPassword: encryptSecret(settings.password) }); + return; + } + await db + .update(mailAccounts) + .set({ ...shared, ...(settings.password ? { smtpPassword: encryptSecret(settings.password) } : {}) }) + .where(eq(mailAccounts.provider, "smtp")); +} + +export async function saveMailConnection(input: { + provider: MailProvider; + accessToken: string; + refreshToken: string | null; + expiresAt: Date | null; + fromAddress: string; + fromName?: string | null; + connectedById: number | null; +}) { + const existing = await getMailAccount(input.provider); + const values = { + accessToken: encryptSecret(input.accessToken), + // Microsoft fait tourner le jeton de rafraîchissement à chaque + // renouvellement ; un `null` ici ne doit pas effacer celui en place. + ...(input.refreshToken ? { refreshToken: encryptSecret(input.refreshToken) } : {}), + expiresAt: input.expiresAt, + fromAddress: input.fromAddress, + ...(input.fromName ? { fromName: input.fromName } : {}), + connectedById: input.connectedById, + connectedAt: new Date(), + lastCheckAt: null, + lastCheckOk: null, + lastCheckError: null, + updatedAt: new Date(), + }; + + if (!existing) { + await db.insert(mailAccounts).values({ provider: input.provider, ...values }); + return; + } + await db.update(mailAccounts).set(values).where(eq(mailAccounts.provider, input.provider)); +} + +/** Désigne le fournisseur expéditeur ; les autres restent configurés. */ +export async function setActiveMailProvider(provider: MailProvider) { + await db.update(mailAccounts).set({ isActive: false }).where(ne(mailAccounts.provider, provider)); + await db.update(mailAccounts).set({ isActive: true, updatedAt: new Date() }).where(eq(mailAccounts.provider, provider)); +} + +/** + * Coupe la connexion sans effacer les identifiants d'application : une + * reconnexion ne redemande pas de recopier l'identifiant et le secret. + */ +export async function disconnectMailAccount(provider: MailProvider) { + await db + .update(mailAccounts) + .set({ + accessToken: null, + refreshToken: null, + expiresAt: null, + fromAddress: null, + isActive: false, + lastCheckAt: null, + lastCheckOk: null, + lastCheckError: null, + updatedAt: new Date(), + }) + .where(eq(mailAccounts.provider, provider)); +} + +export async function getDecryptedAppSecret(provider: MailProvider): Promise { + const account = await getMailAccount(provider); + return safeDecrypt(account?.appSecret ?? null); +} + +// ---- Jetons OAuth ---- + +const TOKEN_ENDPOINT: Record<"google" | "microsoft", string> = { + google: "https://oauth2.googleapis.com/token", + microsoft: "https://login.microsoftonline.com/common/oauth2/v2.0/token", +}; + +/** + * Jeton d'accès valide, renouvelé si besoin. Appelée par l'expéditeur, jamais + * par le rendu d'une page : un renouvellement est une écriture. + */ +export async function ensureAccessToken(provider: "google" | "microsoft"): Promise { + const account = await getMailAccount(provider); + if (!account) return null; + + const current = safeDecrypt(account.accessToken); + const stillValid = + current && account.expiresAt && new Date(account.expiresAt).getTime() - Date.now() > REFRESH_MARGIN_MS; + if (stillValid) return current; + + const refreshToken = safeDecrypt(account.refreshToken); + const appSecret = safeDecrypt(account.appSecret); + if (!refreshToken || !account.appId || !appSecret) return current; + + try { + const res = await fetch(TOKEN_ENDPOINT[provider], { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: account.appId, + client_secret: appSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }), + }); + if (!res.ok) return current; + + const payload = (await res.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!payload.access_token) return current; + + await db + .update(mailAccounts) + .set({ + accessToken: encryptSecret(payload.access_token), + // Microsoft renvoie un nouveau jeton de rafraîchissement à chaque + // appel et invalide l'ancien : ne pas l'enregistrer condamnerait + // l'envoi au bout d'une heure. + ...(payload.refresh_token ? { refreshToken: encryptSecret(payload.refresh_token) } : {}), + expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null, + updatedAt: new Date(), + }) + .where(eq(mailAccounts.provider, provider)); + + return payload.access_token; + } catch { + return current; + } +} + +// ---- Santé ---- + +export type MailHealth = { ok: true; detail: string } | { ok: false; reason: string }; + +async function rememberCheck(provider: MailProvider, health: MailHealth) { + await db + .update(mailAccounts) + .set({ + lastCheckAt: new Date(), + lastCheckOk: health.ok, + lastCheckError: health.ok ? null : health.reason.slice(0, 2000), + }) + .where(eq(mailAccounts.provider, provider)); +} + +async function readError(res: Response): Promise { + const body = await res.text().catch(() => ""); + return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`; +} + +/** + * Interroge le fournisseur. Ne lève jamais : un échec est un verdict, pas une + * exception — l'écran doit pouvoir l'afficher. + */ +export async function checkMailHealth(provider: MailProvider): Promise { + const account = await getMailAccount(provider); + if (!account) return null; + + try { + if (provider === "smtp") { + const password = safeDecrypt(account.smtpPassword); + if (!account.smtpHost || !account.smtpUser || !password) return null; + const { verifySmtp } = await import("@/lib/mailer"); + const health = await verifySmtp({ + host: account.smtpHost, + port: account.smtpPort ?? 465, + secure: account.smtpSecure, + user: account.smtpUser, + password, + }); + await rememberCheck(provider, health); + return health; + } + + const token = await ensureAccessToken(provider); + if (!token) return null; + + const url = + provider === "google" + ? "https://gmail.googleapis.com/gmail/v1/users/me/profile" + : "https://graph.microsoft.com/v1.0/me"; + const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); + const health: MailHealth = res.ok + ? { ok: true, detail: `Boîte accessible (${account.fromAddress ?? "compte connecté"})` } + : { ok: false, reason: await readError(res) }; + await rememberCheck(provider, health); + return health; + } catch (error) { + const health: MailHealth = { + ok: false, + reason: error instanceof Error ? error.message : "Fournisseur injoignable", + }; + await rememberCheck(provider, health); + return health; + } +} + +/** Verdict mis en cache : évite un appel réseau à chaque affichage. */ +export async function mailHealthCached( + provider: MailProvider, + maxAgeMs = 6 * 3_600_000 +): Promise { + const account = await getMailAccount(provider); + if (!account) return null; + + const fresh = account.lastCheckAt && Date.now() - new Date(account.lastCheckAt).getTime() < maxAgeMs; + if (fresh && account.lastCheckOk !== null) { + return account.lastCheckOk + ? { ok: true, detail: "Vérifiée récemment" } + : { ok: false, reason: account.lastCheckError ?? "Boîte refusée" }; + } + return checkMailHealth(provider); +} + +// ---- Parcours OAuth ---- + +export class MailAuthError extends Error {} + +/** + * Portées demandées. + * + * Google : `gmail.send` est classée *sensible*. `https://mail.google.com/`, + * qu'exigerait SMTP avec XOAUTH2, est *restreinte* et déclenche un audit de + * sécurité — d'où le choix de l'API Gmail. + * + * Microsoft : délégué, sur l'autorité `/common`, pour accepter aussi bien une + * boîte professionnelle qu'un compte outlook.com ou hotmail.com personnel. + */ +const SCOPES: Record<"google" | "microsoft", string> = { + google: "https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/userinfo.email", + microsoft: "offline_access Mail.Send User.Read", +}; + +export async function mailRedirectUri(provider: MailProvider, base: string): Promise { + return `${base.replace(/\/+$/, "")}/api/mail/${provider}/callback`; +} + +export async function mailAuthorizeUrl( + provider: "google" | "microsoft", + appId: string, + state: string, + base: string +): Promise { + const redirectUri = await mailRedirectUri(provider, base); + + if (provider === "google") { + return `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({ + client_id: appId, + redirect_uri: redirectUri, + response_type: "code", + scope: SCOPES.google, + // Sans `offline` et `consent`, Google ne délivre pas de jeton de + // rafraîchissement au deuxième passage : l'envoi mourrait au bout d'une + // heure sans raison visible. + access_type: "offline", + prompt: "consent", + include_granted_scopes: "true", + state, + })}`; + } + + return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${new URLSearchParams({ + client_id: appId, + redirect_uri: redirectUri, + response_type: "code", + response_mode: "query", + scope: SCOPES.microsoft, + state, + })}`; +} + +export type MailExchange = { + accessToken: string; + refreshToken: string | null; + expiresAt: Date | null; + fromAddress: string; + fromName: string | null; +}; + +/** + * Échange le code contre des jetons, puis **lit l'adresse chez le + * fournisseur** : Gmail expédie comme l'utilisateur authentifié et Graph comme + * la boîte. Laisser l'administrateur saisir une autre adresse ferait tomber + * SPF et DKIM, et tous les messages partiraient en indésirable. + */ +export async function exchangeMailCode( + provider: "google" | "microsoft", + appId: string, + appSecret: string, + code: string, + base: string +): Promise { + const res = await fetch(TOKEN_ENDPOINT[provider], { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: appId, + client_secret: appSecret, + code, + redirect_uri: await mailRedirectUri(provider, base), + grant_type: "authorization_code", + }), + }); + if (!res.ok) throw new MailAuthError(await readError(res)); + + const payload = (await res.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!payload.access_token) throw new MailAuthError("Aucun jeton d'accès renvoyé."); + + const identity = await readIdentity(provider, payload.access_token); + if (!identity.address) { + throw new MailAuthError("Impossible de lire l'adresse de la boîte connectée."); + } + + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token ?? null, + expiresAt: payload.expires_in ? new Date(Date.now() + payload.expires_in * 1000) : null, + fromAddress: identity.address, + fromName: identity.name, + }; +} + +async function readIdentity( + provider: "google" | "microsoft", + accessToken: string +): Promise<{ address: string; name: string | null }> { + if (provider === "google") { + const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw new MailAuthError(await readError(res)); + const profile = (await res.json()) as { emailAddress?: string }; + return { address: profile.emailAddress ?? "", name: null }; + } + + const res = await fetch("https://graph.microsoft.com/v1.0/me", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) throw new MailAuthError(await readError(res)); + const profile = (await res.json()) as { mail?: string; userPrincipalName?: string; displayName?: string }; + return { address: profile.mail ?? profile.userPrincipalName ?? "", name: profile.displayName ?? null }; +} diff --git a/src/lib/mail-outbox.ts b/src/lib/mail-outbox.ts new file mode 100644 index 0000000..668d973 --- /dev/null +++ b/src/lib/mail-outbox.ts @@ -0,0 +1,207 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { mailMessages } from "@/db/schema"; +import type { MailKind, MailMessage } from "@/db/schema"; +import { sendNow } from "@/lib/mailer"; + +/** + * File d'attente d'envoi. + * + * Une diffusion à quatre-vingts adhérents ne peut pas partir dans la requête + * qui l'a déclenchée : la page attendrait quatre-vingts allers-retours. Les + * messages sont donc écrits en base et vidés par la boucle de fond, sur le + * modèle de `releaseDuePromotions`. + * + * Une ligne par destinataire, jamais de copie partagée : `to_address` est un + * `varchar` unique, la confidentialité est structurelle. + * + * **Les mots de passe temporaires ne passent pas par ici.** Le clair n'existe + * que dans la portée de l'action qui les émet, et `mail_messages.html` est + * stocké en base : ils partent en ligne directe (`sendNow`), et seule une + * trace sans contenu est journalisée. + */ + +/** Débit par passage. Gmail bride autour de quelques centaines par jour. */ +const DEFAULT_BATCH = Number(process.env.MAIL_RATE_PER_MINUTE ?? 20); + +/** Au-delà, le message est abandonné : cinq échecs ne sont pas un incident réseau. */ +export const MAX_ATTEMPTS = 5; + +/** Un verrou plus vieux que cela vient d'un conteneur mort en plein envoi. */ +const STALE_LOCK_MS = 10 * 60_000; + +export type QueuedMail = { + kind: MailKind; + toAddress: string; + toName?: string | null; + subject: string; + html: string; + text?: string | null; + replyTo?: string | null; + informationId?: number | null; + meetingId?: number | null; + memberId?: number | null; + createdById?: number | null; +}; + +/** Attente avant la prochaine tentative : 2, 4, 8, 16 minutes. */ +export function backoffDelayMs(attempts: number): number { + const capped = Math.min(Math.max(attempts, 1), MAX_ATTEMPTS); + return 2 ** capped * 60_000; +} + +export function shouldGiveUp(attempts: number): boolean { + return attempts >= MAX_ATTEMPTS; +} + +export async function queueMails(mails: QueuedMail[]): Promise { + if (mails.length === 0) return 0; + const rows = await db.insert(mailMessages).values(mails).returning({ id: mailMessages.id }); + return rows.length; +} + +export async function queueMail(mail: QueuedMail): Promise { + return queueMails([mail]); +} + +/** + * Journalise un envoi déjà parti, sans son contenu. + * + * Sert aux mots de passe temporaires : le journal doit montrer qu'un message + * est parti, à qui et quand, sans que le corps — donc le mot de passe — ne + * touche la base. + */ +export async function logSentMail(entry: { + kind: MailKind; + toAddress: string; + subject: string; + memberId?: number | null; + createdById?: number | null; + result: { ok: true } | { ok: false; reason: string }; +}) { + await db.insert(mailMessages).values({ + kind: entry.kind, + toAddress: entry.toAddress, + subject: entry.subject, + html: "", + memberId: entry.memberId ?? null, + createdById: entry.createdById ?? null, + status: entry.result.ok ? "sent" : "failed", + attempts: 1, + sentAt: entry.result.ok ? new Date() : null, + error: entry.result.ok ? null : entry.result.reason.slice(0, 2000), + }); +} + +/** + * Réclame un lot de messages à envoyer. + * + * `FOR UPDATE SKIP LOCKED` : deux instances peuvent vider la file en parallèle + * sans jamais expédier deux fois le même message. `releaseDuePromotions` s'en + * passe parce que la transition de statut y fait office de verrou ; ici le + * passage en `sending` doit être exclusif avant l'appel réseau. + */ +async function claimDueMails(limit: number): Promise { + const stale = new Date(Date.now() - STALE_LOCK_MS); + // Un conteneur arrêté en plein envoi laisse des lignes en `sending` : + // on les remet en file avant de servir le lot suivant. + await db + .update(mailMessages) + .set({ status: "queued", lockedAt: null }) + .where(and(eq(mailMessages.status, "sending"), sql`${mailMessages.lockedAt} < ${stale}`)); + + const { rows } = await db.execute(sql` + update ${mailMessages} + set status = 'sending', locked_at = now() + where id in ( + select id from ${mailMessages} + where status = 'queued' and next_attempt_at <= now() + order by id + limit ${limit} + for update skip locked + ) + returning * + `); + return rows; +} + +export type OutboxReport = { sent: number; failed: number }; + +/** Vide un lot de la file. Ne lève jamais : c'est une boucle de fond. */ +export async function processOutbox(limit = DEFAULT_BATCH): Promise { + const report: OutboxReport = { sent: 0, failed: 0 }; + let claimed: MailMessage[] = []; + try { + claimed = await claimDueMails(limit); + } catch (error) { + console.error("[mail] réclamation :", error); + return report; + } + + for (const message of claimed) { + const result = await sendNow({ + to: message.toAddress, + toName: message.toName, + subject: message.subject, + html: message.html, + text: message.text, + replyTo: message.replyTo, + }); + + if (result.ok) { + report.sent += 1; + await db + .update(mailMessages) + .set({ status: "sent", sentAt: new Date(), provider: result.provider, lockedAt: null, error: null }) + .where(eq(mailMessages.id, message.id)); + continue; + } + + report.failed += 1; + const attempts = message.attempts + 1; + const exhausted = shouldGiveUp(attempts); + await db + .update(mailMessages) + .set({ + status: exhausted ? "failed" : "queued", + attempts, + lockedAt: null, + nextAttemptAt: new Date(Date.now() + backoffDelayMs(attempts)), + error: result.reason.slice(0, 2000), + }) + .where(eq(mailMessages.id, message.id)); + } + + return report; +} + +// ---- Journal ---- + +export async function recentMails(limit = 40) { + return db.select().from(mailMessages).orderBy(desc(mailMessages.id)).limit(limit); +} + +export async function mailCountsFor(informationId: number) { + const rows = await db + .select({ status: mailMessages.status, total: sql`count(*)::int` }) + .from(mailMessages) + .where(eq(mailMessages.informationId, informationId)) + .groupBy(mailMessages.status); + return rows.reduce>((acc, row) => ({ ...acc, [row.status]: row.total }), {}); +} + +/** Remet un message échoué en file, immédiatement. */ +export async function retryMailMessage(id: number) { + await db + .update(mailMessages) + .set({ status: "queued", attempts: 0, nextAttemptAt: new Date(), lockedAt: null, error: null }) + .where(and(eq(mailMessages.id, id), eq(mailMessages.status, "failed"))); +} + +/** Annule un message encore en attente ; un message parti ne s'annule pas. */ +export async function cancelMailMessage(id: number) { + await db + .update(mailMessages) + .set({ status: "cancelled", lockedAt: null }) + .where(and(eq(mailMessages.id, id), eq(mailMessages.status, "queued"))); +} diff --git a/src/lib/mail-recipients.ts b/src/lib/mail-recipients.ts new file mode 100644 index 0000000..41585ef --- /dev/null +++ b/src/lib/mail-recipients.ts @@ -0,0 +1,122 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { categories, meetingRegistrations, members, users } from "@/db/schema"; + +/** + * Qui reçoit quoi. + * + * L'adresse retenue est **`members.email`**, l'adresse administrative qui sert + * aussi d'identifiant de connexion — jamais `members.contact_email`, qui est + * l'adresse publique de la fiche et peut pointer vers un standard. + * + * La partie pure (`dedupeRecipients`) est exportée pour être testée sans base : + * c'est elle qui garantit qu'un adhérent ne reçoit pas deux fois le même + * message, et que N destinataires donnent bien N envois. + */ + +export type Recipient = { email: string; name: string; memberId: number | null }; + +export type AudienceKind = "all" | "category" | "members" | "meeting"; + +export type Audience = + | { kind: "all" } + | { kind: "category"; categoryId: number } + | { kind: "members"; memberIds: number[] } + | { kind: "meeting"; meetingId: number }; + +// Volontairement permissif : le rôle de ce contrôle est d'écarter les champs +// vides ou manifestement cassés, pas de réinventer la RFC 5322. +const PLAUSIBLE_EMAIL = /^[^\s@,;<>]+@[^\s@,;<>]+\.[^\s@,;<>]{2,}$/; + +export function isPlausibleEmail(value: string): boolean { + return PLAUSIBLE_EMAIL.test(String(value ?? "").trim()); +} + +/** + * Écarte les adresses vides ou invalides et les doublons, insensiblement à la + * casse. L'ordre d'origine est conservé : la première occurrence gagne, avec + * son nom. + */ +export function dedupeRecipients(rows: { email: string | null; name?: string | null; memberId?: number | null }[]): Recipient[] { + const seen = new Set(); + const out: Recipient[] = []; + for (const row of rows) { + const email = String(row.email ?? "").trim(); + if (!isPlausibleEmail(email)) continue; + const key = email.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push({ email, name: String(row.name ?? "").trim(), memberId: row.memberId ?? null }); + } + return out; +} + +export async function resolveAudience(audience: Audience): Promise { + if (audience.kind === "meeting") { + const rows = await db + .select({ email: meetingRegistrations.attendeeEmail, name: meetingRegistrations.attendeeName, memberId: meetingRegistrations.memberId }) + .from(meetingRegistrations) + .where(eq(meetingRegistrations.meetingId, audience.meetingId)) + .orderBy(asc(meetingRegistrations.id)); + return dedupeRecipients(rows); + } + + const base = db + .select({ email: members.email, name: members.name, memberId: members.id }) + .from(members) + .$dynamic(); + + if (audience.kind === "category") { + return dedupeRecipients( + await base + .where(and(eq(members.status, "active"), eq(members.categoryId, audience.categoryId))) + .orderBy(asc(members.name)), + ); + } + + if (audience.kind === "members") { + if (audience.memberIds.length === 0) return []; + return dedupeRecipients(await base.where(inArray(members.id, audience.memberIds)).orderBy(asc(members.name))); + } + + return dedupeRecipients(await base.where(eq(members.status, "active")).orderBy(asc(members.name))); +} + +/** Les adhérents actifs, pour la diffusion d'une information. */ +export async function activeMemberRecipients(): Promise { + return resolveAudience({ kind: "all" }); +} + +export type AudienceOption = { value: string; label: string; count: number }; + +/** Options proposées par le sélecteur de destinataires. */ +export async function audienceOptions(): Promise<{ all: number; categories: AudienceOption[] }> { + const all = (await activeMemberRecipients()).length; + const rows = await db + .select({ id: categories.id, label: categories.label, email: members.email, memberId: members.id }) + .from(categories) + .innerJoin(members, and(eq(members.categoryId, categories.id), eq(members.status, "active"))) + .orderBy(asc(categories.sort), asc(categories.label)); + + const grouped = new Map(); + for (const row of rows) { + const entry = grouped.get(row.id) ?? { label: row.label, rows: [] }; + entry.rows.push({ email: row.email, memberId: row.memberId }); + grouped.set(row.id, entry); + } + + return { + all, + categories: [...grouped.entries()].map(([id, entry]) => ({ + value: String(id), + label: entry.label, + count: dedupeRecipients(entry.rows).length, + })), + }; +} + +/** L'adresse de l'utilisateur courant, pour « m'envoyer un test ». */ +export async function selfRecipient(userId: number): Promise { + const [row] = await db.select({ email: users.email, name: users.name }).from(users).where(eq(users.id, userId)); + return dedupeRecipients(row ? [{ ...row, memberId: null }] : [])[0] ?? null; +} diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts new file mode 100644 index 0000000..a8ee9a9 --- /dev/null +++ b/src/lib/mailer.ts @@ -0,0 +1,141 @@ +import { buildMimeMessage, formatAddress, toBase64Url, utf8ToBase64 } from "@/lib/mime"; +import { resolveMailSender, type MailHealth, type MailSender } from "@/lib/mail-accounts"; + +/** + * Expédition d'un message. + * + * Trois transports, deux familles : + * + * - **Google** passe par l'API Gmail (`users.messages.send`) et non par SMTP + * avec XOAUTH2 : celui-ci exigerait la portée `https://mail.google.com/`, + * classée « restreinte », donc un audit de sécurité. `gmail.send` est une + * portée simplement « sensible ». + * - **Microsoft** passe par Graph (`/me/sendMail`) : l'authentification + * basique SMTP est désactivée par défaut depuis 2024, y compris sur les + * boîtes outlook.com et hotmail.com. + * - **SMTP** couvre tout le reste (mot de passe d'application Gmail, Outlook + * professionnel, OVH, Ionos…) via `nodemailer`. + * + * `sendNow` ne lève jamais : elle renvoie un verdict, que la file d'attente ou + * l'action appelante affiche. Un envoi raté ne doit pas faire tomber la page + * qui l'a déclenché. + */ + +export type OutgoingMail = { + to: string; + toName?: string | null; + subject: string; + html: string; + text?: string | null; + replyTo?: string | null; +}; + +export type SendResult = + | { ok: true; provider: MailSender["provider"] } + | { ok: false; reason: string }; + +export type SmtpConfig = { host: string; port: number; secure: boolean; user: string; password: string }; + +async function readError(res: Response): Promise { + const body = await res.text().catch(() => ""); + return `HTTP ${res.status}${body ? ` — ${body.slice(0, 400)}` : ""}`; +} + +export async function sendNow(mail: OutgoingMail): Promise { + const sender = await resolveMailSender(); + if (!sender) { + return { ok: false, reason: "Aucune boîte mail n'est configurée (Backend › Boîte mail)." }; + } + + try { + if (sender.provider === "google") return await sendViaGmail(sender, mail); + if (sender.provider === "microsoft") return await sendViaGraph(sender, mail); + return await sendViaSmtp(sender, mail); + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "Envoi impossible" }; + } +} + +async function sendViaGmail(sender: MailSender, mail: OutgoingMail): Promise { + // Le champ `raw` de l'API Gmail attend le message RFC 822 en base64url. + const raw = toBase64Url( + utf8ToBase64( + buildMimeMessage({ + from: formatAddress(sender.fromAddress, sender.fromName), + to: formatAddress(mail.to, mail.toName), + replyTo: mail.replyTo ?? undefined, + subject: mail.subject, + html: mail.html, + text: mail.text ?? undefined, + }), + ), + ); + + const res = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", { + method: "POST", + headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ raw }), + }); + if (!res.ok) return { ok: false, reason: await readError(res) }; + return { ok: true, provider: "google" }; +} + +async function sendViaGraph(sender: MailSender, mail: OutgoingMail): Promise { + // Graph prend du JSON : aucun MIME à construire de ce côté. + const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", { + method: "POST", + headers: { Authorization: `Bearer ${sender.accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + message: { + subject: mail.subject, + body: { contentType: "HTML", content: mail.html }, + toRecipients: [{ emailAddress: { address: mail.to, name: mail.toName ?? undefined } }], + ...(mail.replyTo ? { replyTo: [{ emailAddress: { address: mail.replyTo } }] } : {}), + }, + saveToSentItems: true, + }), + }); + if (!res.ok) return { ok: false, reason: await readError(res) }; + return { ok: true, provider: "microsoft" }; +} + +async function sendViaSmtp(sender: MailSender, mail: OutgoingMail): Promise { + if (!sender.smtp) return { ok: false, reason: "Réglages SMTP incomplets." }; + const transport = await smtpTransport(sender.smtp); + await transport.sendMail({ + from: formatAddress(sender.fromAddress, sender.fromName), + to: formatAddress(mail.to, mail.toName), + replyTo: mail.replyTo ?? undefined, + subject: mail.subject, + html: mail.html, + text: mail.text ?? undefined, + }); + return { ok: true, provider: "smtp" }; +} + +/** + * `nodemailer` charge `net`, `tls` et `dns` par des requires dynamiques : il + * est importé à la demande, et déclaré dans `serverExternalPackages`, pour ne + * jamais entrer dans un bundle qui ne les a pas. + */ +async function smtpTransport(config: SmtpConfig) { + const nodemailer = (await import("nodemailer")).default; + return nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + auth: { user: config.user, pass: config.password }, + }); +} + +/** Contrôle de santé SMTP : ouvre la connexion, s'authentifie, referme. */ +export async function verifySmtp(config: SmtpConfig): Promise { + try { + const transport = await smtpTransport(config); + await transport.verify(); + transport.close(); + return { ok: true, detail: `Connexion établie avec ${config.host}` }; + } catch (error) { + return { ok: false, reason: error instanceof Error ? error.message : "Serveur SMTP injoignable" }; + } +} diff --git a/src/lib/site-settings.ts b/src/lib/site-settings.ts index 2c9ba60..eadde1b 100644 --- a/src/lib/site-settings.ts +++ b/src/lib/site-settings.ts @@ -1,3 +1,4 @@ +import type { EmailBrand } from "@/lib/email-templates"; import { asc } from "drizzle-orm"; import { db } from "@/db"; import { siteSettings } from "@/db/schema"; @@ -122,3 +123,18 @@ export function parseBoardMembers(value: string) { return { name, role: role || "Membre du directoire" }; }); } + +/** + * Identité de l'association telle qu'elle apparaît en pied de tous les + * e-mails. Elle sert au studio de composition comme aux envois automatiques : + * un seul endroit à changer. + */ +export function emailBrand(settings: SiteSettings): EmailBrand { + return { + associationName: settings.association_name, + address: settings.association_address, + email: settings.association_email, + phone: settings.association_phone, + siret: settings.association_siret, + }; +}