/** * Service email — Resend si `RESEND_API_KEY` est configuré, sinon log console. * * Le code consommateur ne doit jamais bloquer ni jeter d'erreur sur un échec * d'envoi (best-effort, le booking est l'action principale). */ import "server-only"; let resendClient: import("resend").Resend | null | undefined; async function getResend(): Promise { if (resendClient !== undefined) return resendClient; const key = process.env.RESEND_API_KEY?.trim(); if (!key) { resendClient = null; return null; } try { const { Resend } = await import("resend"); resendClient = new Resend(key); return resendClient; } catch (e) { console.error("[email] resend init failed:", e instanceof Error ? e.message : e); resendClient = null; return null; } } export type EmailOpts = { to: string | string[]; subject: string; html: string; text?: string; replyTo?: string; }; const DEFAULT_FROM = process.env.RESEND_FROM ?? "Karbé "; export async function sendEmail(opts: EmailOpts): Promise<{ ok: boolean; id?: string; reason?: string }> { const client = await getResend(); if (!client) { console.log( "[email] dry-run (no RESEND_API_KEY):", JSON.stringify({ to: opts.to, subject: opts.subject }), ); return { ok: true, reason: "dry-run" }; } try { const { data, error } = await client.emails.send({ from: DEFAULT_FROM, to: Array.isArray(opts.to) ? opts.to : [opts.to], subject: opts.subject, html: opts.html, text: opts.text, replyTo: opts.replyTo, }); if (error) { console.error("[email] resend error:", error); return { ok: false, reason: error.message }; } return { ok: true, id: data?.id }; } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.error("[email] send failed:", msg); return { ok: false, reason: msg }; } } // ---------- Templates ---------- const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr"; const baseStyle = ` font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #18181b; max-width: 580px; margin: 0 auto; padding: 24px; line-height: 1.5; `; function wrap(title: string, content: string): string { return `

${title}

${content}

Karbé · ${SITE_URL}
Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.

`; } export async function sendSignupWelcome(to: string, firstName: string): Promise { await sendEmail({ to, subject: "Bienvenue sur Karbé", html: wrap( `Bienvenue ${firstName} !`, `

Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.

Découvrir les carbets

`, ), text: `Bienvenue ${firstName} ! Votre compte Karbé est créé. ${SITE_URL}/carbets`, }); } export async function sendBookingRequestToTenant( to: string, firstName: string, bookingId: string, carbetTitle: string, startDate: Date, endDate: Date, amount: string, currency: string, ): Promise { const fmt = (d: Date) => new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); await sendEmail({ to, subject: `Demande de réservation enregistrée — ${carbetTitle}`, html: wrap( "Demande de réservation envoyée", `

Bonjour ${firstName},

Votre demande de réservation pour ${carbetTitle} a bien été enregistrée :

  • Arrivée : ${fmt(startDate)}
  • Départ : ${fmt(endDate)}
  • Montant : ${Number(amount).toFixed(2)} ${currency}

Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.

Voir ma réservation

`, ), }); } export async function sendBookingRequestToOwner( to: string, ownerFirstName: string, bookingId: string, carbetTitle: string, tenantName: string, startDate: Date, endDate: Date, ): Promise { const fmt = (d: Date) => new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); await sendEmail({ to, subject: `Nouvelle demande de réservation — ${carbetTitle}`, html: wrap( "Nouvelle demande à confirmer", `

Bonjour ${ownerFirstName},

${tenantName} souhaite réserver ${carbetTitle} :

  • Du ${fmt(startDate)} au ${fmt(endDate)}

Connectez-vous à votre espace hôte pour confirmer ou refuser.

Mon espace hôte

`, ), }); } export async function sendBookingConfirmed( to: string, firstName: string, bookingId: string, carbetTitle: string, startDate: Date, endDate: Date, ): Promise { const fmt = (d: Date) => new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); await sendEmail({ to, subject: `Réservation confirmée — ${carbetTitle}`, html: wrap( "Votre séjour est confirmé", `

Bonjour ${firstName},

Votre réservation pour ${carbetTitle} du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.

Voir ma réservation

`, ), }); } export async function sendNewCeRequest( orgName: string, managerEmail: string, ): Promise { const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr"; await sendEmail({ to: adminEmail, subject: `Nouvelle demande CE — ${orgName}`, html: wrap( "Demande de Comité d'Entreprise à valider", `

Une organisation vient de s'inscrire en tant que Comité d'Entreprise.

  • Nom : ${orgName}
  • Email du manager : ${managerEmail}

Valider sur l'admin Karbé

Le CE_MANAGER peut accéder à son dashboard mais ne peut rien publier tant que Organization.approved=false.

`, ), }); } export async function sendCeInviteEmail( to: string, orgName: string, inviteUrl: string, inviterName?: string | null, ): Promise { const intro = inviterName ? `${inviterName} vous invite à rejoindre` : "Vous êtes invité à rejoindre"; await sendEmail({ to, subject: `Invitation à rejoindre « ${orgName} » sur Karbé`, html: wrap( `Invitation Karbé — ${orgName}`, `

${intro} le Comité d'Entreprise ${orgName} sur Karbé.

Cliquez sur le bouton ci-dessous pour créer votre compte CE_MEMBER et accéder aux carbets et matériel de votre CE :

Rejoindre ${orgName}

Lien valable 14 jours. Si vous n'êtes pas le destinataire attendu, ignorez cet email.

Lien direct : ${inviteUrl}

`, ), text: `${intro.replace(/<[^>]+>/g, "")} le CE ${orgName} sur Karbé : ${inviteUrl}`, }); } export async function sendCeApproved( to: string, firstName: string, orgName: string, ): Promise { await sendEmail({ to, subject: `Votre CE « ${orgName} » est validé sur Karbé`, html: wrap( "Organisation validée", `

Bonjour ${firstName},

Votre Comité d'Entreprise ${orgName} vient d'être validé. Vous pouvez désormais publier vos carbets et activer la location de matériel pour vos membres et le public touriste.

Accéder à mon espace CE

`, ), }); } export async function sendNewRentalProviderRequest( providerName: string, userEmail: string, ): Promise { const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr"; await sendEmail({ to: adminEmail, subject: `Nouvelle demande prestataire matériel — ${providerName}`, html: wrap( "Demande de prestataire à valider", `

Une demande d'inscription en tant que prestataire de location matériel vient d'arriver.

  • Nom : ${providerName}
  • Email contact : ${userEmail}

Valider sur l'admin Karbé

Le prestataire reste en attente jusqu'à validation. Ses items ne sont pas publiés tant que approved=false.

`, ), }); } export async function sendPasswordReset( to: string, resetUrl: string, ): Promise { await sendEmail({ to, subject: "Réinitialisation de votre mot de passe Karbé", html: wrap( "Réinitialiser votre mot de passe", `

Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :

Réinitialiser mon mot de passe

Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.

`, ), text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`, }); } type RentalLineSummary = { qty: number; itemName: string }; function renderLines(lines: RentalLineSummary[]): string { return lines.map((l) => `
  • ${l.qty}× ${l.itemName}
  • `).join(""); } export async function sendRentalRequestedTenant( to: string, firstName: string, rentalBookingId: string, providerName: string, startDate: Date, endDate: Date, amount: string, currency: string, lines: RentalLineSummary[], ): Promise { const fmt = (d: Date) => new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); await sendEmail({ to, subject: `Demande de location matériel — ${providerName}`, html: wrap( "Votre demande de location est enregistrée", `

    Bonjour ${firstName},

    Votre demande de location auprès de ${providerName} est bien enregistrée :

    • Du ${fmt(startDate)} au ${fmt(endDate)}
    • Montant : ${Number(amount).toFixed(2)} ${currency}

    Matériel demandé :

      ${renderLines(lines)}

    Vous recevrez un nouvel email dès que le paiement sera validé et le prestataire confirmera la préparation du matériel.

    Mes locations

    Référence : ${rentalBookingId}

    `, ), }); } export async function sendRentalRequestedProvider( to: string, providerName: string, rentalBookingId: string, tenantName: string, startDate: Date, endDate: Date, lines: RentalLineSummary[], ): Promise { const fmt = (d: Date) => new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); await sendEmail({ to, subject: `Nouvelle demande de location — ${tenantName}`, html: wrap( "Nouvelle demande à préparer", `

    Bonjour ${providerName},

    ${tenantName} vient de réserver du matériel :

    • Du ${fmt(startDate)} au ${fmt(endDate)}

    Matériel :

      ${renderLines(lines)}

    Préparez le matériel pour la remise. Vous recevrez une confirmation paiement une fois le règlement validé.

    Mes réservations

    Référence : ${rentalBookingId}

    `, ), }); } export async function sendRentalCancelled( to: string, firstName: string, rentalBookingId: string, providerName: string, refundAmount: string, currency: string, policyLabel: string, cancelledBy: "tenant" | "provider" | "admin", ): Promise { const actor = cancelledBy === "tenant" ? "Vous avez annulé" : cancelledBy === "provider" ? `${providerName} a annulé` : "L'équipe Karbé a annulé"; await sendEmail({ to, subject: `Location annulée — ${providerName}`, html: wrap( "Location annulée", `

    Bonjour ${firstName},

    ${actor} votre location auprès de ${providerName}.

    Politique appliquée : ${policyLabel}

    Remboursement : ${Number(refundAmount).toFixed(2)} ${currency}

    Si un paiement avait été reçu, le remboursement est traité par Stripe sous 3-5 jours ouvrés.

    Mes locations

    Référence : ${rentalBookingId}

    `, ), }); } export async function sendRentalConfirmed( to: string, firstName: string, rentalBookingId: string, providerName: string, startDate: Date, endDate: Date, ): Promise { const fmt = (d: Date) => new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(d); await sendEmail({ to, subject: `Location confirmée — ${providerName}`, html: wrap( "Votre location est confirmée", `

    Bonjour ${firstName},

    Le paiement de votre location auprès de ${providerName} du ${fmt(startDate)} au ${fmt(endDate)} est validé.

    Le prestataire vous contactera pour organiser la remise du matériel sur place.

    Voir ma location

    Référence : ${rentalBookingId}

    `, ), }); } export async function sendPayoutSent( to: string, providerName: string, periodMonth: Date, amount: string, reference: string | null, ): Promise { const monthLabel = periodMonth.toLocaleDateString("fr-FR", { timeZone: "UTC", year: "numeric", month: "long", }); await sendEmail({ to, subject: `Reversement Karbé — ${monthLabel}`, html: wrap( `Reversement ${monthLabel}`, `

    Bonjour ${providerName},

    Le reversement de vos locations matériel pour ${monthLabel} a été effectué :

    • Montant : ${Number(amount).toFixed(2)} EUR
    • ${reference ? `
    • Référence virement : ${reference}
    • ` : ""}

    Vérifiez votre compte bancaire dans les 1 à 3 jours ouvrés. En cas de question, répondez à cet email.

    Voir mes réservations

    `, ), }); } export async function sendBookingRefunded( to: string, firstName: string, bookingId: string, carbetTitle: string, amount: string, currency: string, ): Promise { await sendEmail({ to, subject: `Remboursement traité — ${carbetTitle}`, html: wrap( "Remboursement en cours", `

    Bonjour ${firstName},

    Votre réservation pour ${carbetTitle} a été annulée et le remboursement de ${Number(amount).toFixed(2)} ${currency} est en cours de traitement par Stripe (3 à 5 jours ouvrés).

    Détails de la réservation

    `, ), }); }