diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index acf393c..e67dae6 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -51,12 +51,20 @@ import { saveOAuthApp, saveSmtpAccount, setActiveMailProvider, + isMailConfigured, } from "@/lib/mail-accounts"; import type { MailProvider } from "@/db/schema"; import { sendNow } from "@/lib/mailer"; import { cancelMailMessage, logSentMail, queueMails, retryMailMessage } from "@/lib/mail-outbox"; -import { activeMemberRecipients, selfRecipient } from "@/lib/mail-recipients"; -import { buildCredentialsEmail, buildGeneralEmail, buildInformationEmail } from "@/lib/email-templates"; +import { activeMemberRecipients, resolveAudience, selfRecipient, type Audience } from "@/lib/mail-recipients"; +import { + buildCredentialsEmail, + buildGeneralEmail, + buildInformationEmail, + buildMeetingEmail, + type GeneralEmailContent, + type MeetingEmailTexts, +} from "@/lib/email-templates"; import { richTextToPlain } from "@/lib/rich-text"; import { emailBrand, getSiteSettings } from "@/lib/site-settings"; import { siteUrl } from "@/lib/social-accounts"; @@ -1774,3 +1782,146 @@ export async function cancelMail(formData: FormData) { if (id) await cancelMailMessage(id); revalidatePath("/backend/boite-mail"); } + +// ---- Envois groupés ---- + +async function requireEmails() { + const access = await requireRole(); + if (!can(access.role, "manageEmails")) throw new Error("Accès refusé"); + return access; +} + +export type QueuedBroadcast = { queued: number; audience: string }; + +/** + * Prépare un envoi groupé. + * + * Le message est construit **une seule fois** puis recopié par destinataire : + * une ligne chacun, jamais de copie partagée. Rien ne part dans la requête — + * la file est vidée par la boucle de fond, sinon la page attendrait autant + * d'allers-retours que d'adhérents. + */ +async function queueBroadcast( + message: { subject: string; html: string; text?: string | null }, + audience: Audience, + meta: { kind: "studio" | "meeting"; meetingId?: number | null; userId: number | null } +): Promise { + const recipients = await resolveAudience(audience); + if (recipients.length === 0) { + return { error: "Aucun destinataire n'a d'adresse e-mail utilisable pour cette sélection." }; + } + if (!(await isMailConfigured())) { + return { error: "Aucune boîte mail n'est configurée : voir Configuration › Boîte mail." }; + } + + const settings = await getSiteSettings(); + await queueMails( + recipients.map((recipient) => ({ + kind: meta.kind, + toAddress: recipient.email, + toName: recipient.name, + subject: message.subject, + html: message.html, + text: message.text ?? null, + replyTo: settings.association_email || null, + meetingId: meta.meetingId ?? null, + memberId: recipient.memberId, + createdById: meta.userId, + })) + ); + + revalidatePath("/backend/boite-mail"); + return { queued: recipients.length, audience: describeAudience(audience) }; +} + +function describeAudience(audience: Audience): string { + if (audience.kind === "category") return "une catégorie de métier"; + if (audience.kind === "members") return "une sélection d'adhérents"; + if (audience.kind === "meeting") return "les inscrits à la rencontre"; + return "tous les adhérents actifs"; +} + +/** Envoi de contrôle vers sa propre adresse : direct, pour un verdict immédiat. */ +export async function sendSelfTest( + message: { subject: string; html: string; text?: string | null } +): Promise { + const { userId } = await requireEmails(); + const recipient = userId ? await selfRecipient(userId) : null; + if (!recipient) return { error: "Votre compte n'a pas d'adresse e-mail utilisable." }; + + const settings = await getSiteSettings(); + const result = await sendNow({ + to: recipient.email, + toName: recipient.name, + subject: message.subject, + html: message.html, + text: message.text ?? null, + replyTo: settings.association_email || null, + }); + await logSentMail({ + kind: "test", + toAddress: recipient.email, + subject: message.subject, + createdById: userId, + result: result.ok ? { ok: true } : { ok: false, reason: result.reason }, + }); + if (!result.ok) return { error: result.reason }; + return { queued: 1, audience: `votre adresse (${recipient.email})` }; +} + +export async function sendStudioEmail(input: { + content: GeneralEmailContent; + audience: Audience; +}): Promise { + const { userId } = await requireEmails(); + const settings = await getSiteSettings(); + const base = await siteUrl(); + if (!base) { + return { error: "Renseignez d'abord l'adresse publique du site (Backend › Réseaux sociaux)." }; + } + + const { subject, html } = buildGeneralEmail(input.content, base, emailBrand(settings)); + return queueBroadcast({ subject, html, text: input.content.body }, input.audience, { kind: "studio", userId }); +} + +export async function sendMeetingInvitations(input: { + meetingId: number; + texts: MeetingEmailTexts; + audience: Audience; +}): Promise { + const { userId } = await requireEmails(); + const settings = await getSiteSettings(); + const base = await siteUrl(); + if (!base) { + return { error: "Renseignez d'abord l'adresse publique du site (Backend › Réseaux sociaux)." }; + } + + const [meeting] = await db.select().from(meetings).where(eq(meetings.id, input.meetingId)); + if (!meeting) return { error: "Cette rencontre n'existe plus." }; + + const [count] = await db + .select({ total: sql`count(*)::int` }) + .from(meetingRegistrations) + .where(eq(meetingRegistrations.meetingId, meeting.id)); + + const { subject, html, registrationUrl } = buildMeetingEmail( + { + id: meeting.id, + title: meeting.title, + startsAt: meeting.startsAt.toISOString(), + location: meeting.location, + description: meeting.description, + capacity: meeting.capacity, + registered: count?.total ?? 0, + }, + input.texts, + base, + emailBrand(settings) + ); + + return queueBroadcast( + { subject, html, text: `${input.texts.intro}\n\n${registrationUrl}` }, + input.audience, + { kind: "meeting", meetingId: meeting.id, userId } + ); +} diff --git a/src/app/backend/emails/page.tsx b/src/app/backend/emails/page.tsx index 2f4f9e2..174758e 100644 --- a/src/app/backend/emails/page.tsx +++ b/src/app/backend/emails/page.tsx @@ -3,13 +3,29 @@ import { getSession } from "@/lib/session"; import { EmailCreator } from "@/components/EmailCreator"; import { can } from "@/lib/rbac"; import { emailBrand, getSiteSettings } from "@/lib/site-settings"; +import { audienceOptions } from "@/lib/mail-recipients"; +import { isMailConfigured } from "@/lib/mail-accounts"; +import { siteUrl } from "@/lib/social-accounts"; export const dynamic = "force-dynamic"; export default async function EmailsPage() { const session = await getSession(); if (!can(session?.user.role, "manageEmails")) redirect("/backend"); - const settings = await getSiteSettings(); - return ; + const [settings, choices, mailReady, base] = await Promise.all([ + getSiteSettings(), + audienceOptions(), + isMailConfigured(), + siteUrl(), + ]); + + return ( + + ); } diff --git a/src/app/backend/rencontres/page.tsx b/src/app/backend/rencontres/page.tsx index 2b2416b..1659574 100644 --- a/src/app/backend/rencontres/page.tsx +++ b/src/app/backend/rencontres/page.tsx @@ -8,7 +8,10 @@ import { meetingRegistrations, meetings } from "@/db/schema"; import { ImageField } from "@/components/ImageField"; import { MeetingEmailComposer } from "@/components/MeetingEmailComposer"; import { can } from "@/lib/rbac"; -import { getSiteSettings } from "@/lib/site-settings"; +import { emailBrand, getSiteSettings } from "@/lib/site-settings"; +import { audienceOptions } from "@/lib/mail-recipients"; +import { isMailConfigured } from "@/lib/mail-accounts"; +import { siteUrl } from "@/lib/social-accounts"; import { createMeeting, deleteMeeting, updateMeeting } from "../actions"; export const dynamic = "force-dynamic"; @@ -23,7 +26,8 @@ export default async function RencontresAdminPage() { const session = await getSession(); if (!can(session?.user.role, "manageMeetings")) redirect("/backend"); - const [rows, settings] = await Promise.all([ + const canSend = can(session?.user.role, "manageEmails"); + const [rows, settings, choices, mailReady, base] = await Promise.all([ db .select({ id: meetings.id, @@ -41,14 +45,11 @@ export default async function RencontresAdminPage() { .groupBy(meetings.id) .orderBy(desc(meetings.startsAt)), getSiteSettings(), + audienceOptions(), + isMailConfigured(), + siteUrl(), ]); - const emailBrand = { - associationName: settings.association_name, - address: settings.association_address, - email: settings.association_email, - phone: settings.association_phone, - siret: settings.association_siret, - }; + const brand = emailBrand(settings); return (
@@ -139,7 +140,15 @@ export default async function RencontresAdminPage() { capacity: meeting.capacity, registered: Number(meeting.registered), }} - brand={emailBrand} + brand={brand} + choices={{ + ...choices, + meeting: { id: meeting.id, title: meeting.title, count: Number(meeting.registered) }, + }} + // Un modérateur gère les rencontres mais n'écrit pas à tous les + // adhérents : l'envoi reste réservé au studio d'e-mails. + mailReady={canSend && mailReady} + siteUrl={base} />
diff --git a/src/components/EmailCreator.tsx b/src/components/EmailCreator.tsx index c4069cf..c44b228 100644 --- a/src/components/EmailCreator.tsx +++ b/src/components/EmailCreator.tsx @@ -3,9 +3,32 @@ import { useEffect, useMemo, useState } from "react"; import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client"; import { buildGeneralEmail, type EmailBrand, type GeneralEmailContent } from "@/lib/email-templates"; +import { + MailRecipientPicker, + audienceCount, + audienceFrom, + type AudienceState, + type RecipientChoices, +} from "@/components/MailRecipientPicker"; +import { sendSelfTest, sendStudioEmail } from "@/app/backend/actions"; -export function EmailCreator({ brand }: { brand: EmailBrand }) { - const [baseUrl, setBaseUrl] = useState(""); +export function EmailCreator({ + brand, + choices, + mailReady, + siteUrl, +}: { + brand: EmailBrand; + choices: RecipientChoices; + /** Faux si aucune boîte n'expédie : l'envoi réel est alors désactivé. */ + mailReady: boolean; + /** Adresse publique enregistrée, utilisée par l'aperçu comme par l'envoi. */ + siteUrl: string; +}) { + const [baseUrl, setBaseUrl] = useState(siteUrl); + const [audience, setAudience] = useState({ kind: "all", categoryId: "" }); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(null); const [form, setForm] = useState({ subject: `Actualités de ${brand.associationName}`, kicker: brand.associationName, @@ -19,15 +42,35 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) { const [error, setError] = useState(""); useEffect(() => { - setBaseUrl(window.location.origin); - setForm((current) => ({ ...current, buttonUrl: `${window.location.origin}/association` })); - }, []); + // L'adresse enregistrée prime : c'est elle que verront les destinataires, + // et non celle par laquelle l'administrateur est arrivé. + const origin = siteUrl || window.location.origin; + setBaseUrl(origin); + setForm((current) => ({ ...current, buttonUrl: `${origin}/association` })); + }, [siteUrl]); const email = useMemo( () => (baseUrl ? buildGeneralEmail(form, baseUrl, brand) : null), [baseUrl, brand, form], ); + const recipientCount = audienceCount(audience, choices); + + async function deliver(task: () => Promise<{ queued: number; audience: string } | { error: string }>) { + setSending(true); + setError(""); + setSent(null); + try { + const result = await task(); + if ("error" in result) setError(result.error); + else setSent(`✓ ${result.queued} message(s) mis en file pour ${result.audience}.`); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Envoi impossible."); + } finally { + setSending(false); + } + } + function change(name: keyof GeneralEmailContent, value: string) { setForm((current) => ({ ...current, [name]: value })); } @@ -69,8 +112,37 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) { change("signature", value)} />
+
+ +
+ + + {sent &&
{sent}
} + {!mailReady && ( +

+ Aucune boîte mail n'expédie pour l'instant : branchez-la dans Configuration › Boîte mail. + Les boutons ci-dessous restent utilisables. +

+ )} +
+
+ {error &&
{error}
} -
+
@@ -87,7 +159,8 @@ export function EmailCreator({ brand }: { brand: EmailBrand }) {

- Ouvrez fichier .eml pour obtenir un brouillon Outlook prêt à compléter avec destinataires. + Ces trois boutons restent là pour les cas particuliers : le .eml donne un brouillon Outlook + à compléter à la main.

diff --git a/src/components/MailRecipientPicker.tsx b/src/components/MailRecipientPicker.tsx new file mode 100644 index 0000000..ec11f0a --- /dev/null +++ b/src/components/MailRecipientPicker.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type { Audience } from "@/lib/mail-recipients"; + +/** + * Choix des destinataires, partagé par le studio de composition et le + * composeur d'invitations. + * + * Les effectifs sont calculés côté serveur et passés en props : le navigateur + * n'a jamais besoin de la liste des adresses pour afficher un compteur. + */ + +export type RecipientChoices = { + all: number; + categories: { value: string; label: string; count: number }[]; + meeting?: { id: number; title: string; count: number } | null; +}; + +export type AudienceState = { kind: Audience["kind"]; categoryId: string }; + +export function audienceFrom(state: AudienceState, meetingId?: number | null): Audience { + if (state.kind === "category" && state.categoryId) { + return { kind: "category", categoryId: Number(state.categoryId) }; + } + if (state.kind === "meeting" && meetingId) return { kind: "meeting", meetingId }; + return { kind: "all" }; +} + +export function audienceCount(state: AudienceState, choices: RecipientChoices): number { + if (state.kind === "category") { + return choices.categories.find((option) => option.value === state.categoryId)?.count ?? 0; + } + if (state.kind === "meeting") return choices.meeting?.count ?? 0; + return choices.all; +} + +export function MailRecipientPicker({ + choices, + value, + onChange, +}: { + choices: RecipientChoices; + value: AudienceState; + onChange: (next: AudienceState) => void; +}) { + const options: { kind: Audience["kind"]; label: string; hint: string; count: number }[] = [ + { kind: "all", label: "Tous les adhérents actifs", hint: "", count: choices.all }, + ]; + if (choices.categories.length > 0) { + options.push({ kind: "category", label: "Une catégorie de métier", hint: "", count: 0 }); + } + if (choices.meeting) { + options.push({ + kind: "meeting", + label: "Les inscrits à cette rencontre", + hint: choices.meeting.title, + count: choices.meeting.count, + }); + } + + return ( +
+ Destinataires +
+ {options.map((option) => ( +
+ + + {option.kind === "category" && value.kind === "category" && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/src/components/MeetingEmailComposer.tsx b/src/components/MeetingEmailComposer.tsx index f7e12dc..4324730 100644 --- a/src/components/MeetingEmailComposer.tsx +++ b/src/components/MeetingEmailComposer.tsx @@ -4,20 +4,64 @@ import { useMemo, useState } from "react"; import { Modal } from "@/components/Modal"; import { copyRichEmail, downloadHtml, downloadOutlookDraft } from "@/lib/email-client"; import { buildMeetingEmail, defaultMeetingEmailTexts, type EmailBrand, type MeetingEmailData, type MeetingEmailTexts } from "@/lib/email-templates"; +import { + MailRecipientPicker, + audienceCount, + audienceFrom, + type AudienceState, + type RecipientChoices, +} from "@/components/MailRecipientPicker"; +import { sendMeetingInvitations } from "@/app/backend/actions"; -export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmailData; brand: EmailBrand }) { +export function MeetingEmailComposer({ + meeting, + brand, + choices, + mailReady, + siteUrl, +}: { + meeting: MeetingEmailData; + brand: EmailBrand; + choices: RecipientChoices; + mailReady: boolean; + siteUrl: string; +}) { const [open, setOpen] = useState(false); - const [baseUrl, setBaseUrl] = useState(""); + const [baseUrl, setBaseUrl] = useState(siteUrl); const [texts, setTexts] = useState(() => defaultMeetingEmailTexts(brand)); const [flash, setFlash] = useState(""); const [error, setError] = useState(""); + const [audience, setAudience] = useState({ kind: "all", categoryId: "" }); + const [sending, setSending] = useState(false); + const [sent, setSent] = useState(null); const email = useMemo(() => (baseUrl ? buildMeetingEmail(meeting, texts, baseUrl, brand) : null), [baseUrl, brand, meeting, texts]); + const recipientCount = audienceCount(audience, choices); function show() { - setBaseUrl(window.location.origin); + // L'adresse enregistrée prime : c'est celle que verront les destinataires. + setBaseUrl(siteUrl || window.location.origin); setOpen(true); } + async function deliver() { + setSending(true); + setError(""); + setSent(null); + try { + const result = await sendMeetingInvitations({ + meetingId: meeting.id, + texts, + audience: audienceFrom(audience, meeting.id), + }); + if ("error" in result) setError(result.error); + else setSent(`✓ ${result.queued} invitation(s) mises en file pour ${result.audience}.`); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Envoi impossible."); + } finally { + setSending(false); + } + } + function change(name: keyof MeetingEmailTexts, value: string) { setTexts((current) => ({ ...current, [name]: value })); } @@ -46,8 +90,26 @@ export function MeetingEmailComposer({ meeting, brand }: { meeting: MeetingEmail

Date, lieu, description, places et lien d'inscription repris automatiquement.

+
+ +
{error &&
{error}
} - + {!mailReady && ( +

+ Aucune boîte mail ne répond : branchez-la dans Configuration › Boîte mail. Les boutons + ci-dessous restent utilisables. +

+ )} +