karbe/src/lib/email.ts
Claude Integration a6df96db7e
All checks were successful
CI / test (pull_request) Successful in 2m19s
feat: reset password + page mon-compte (RGPD) + facettes recherche (prix max, équipements)
2026-06-01 10:16:37 +00:00

224 lines
8.1 KiB
TypeScript

/**
* 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<import("resend").Resend | null> {
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é <no-reply@karbe.cosmolan.fr>";
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 `<!doctype html><html><body style="background:#fafafa;margin:0;padding:24px 12px;">
<div style="${baseStyle}background:white;border-radius:12px;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
<h1 style="margin:0 0 16px;font-size:22px;font-weight:600;color:#18181b;">${title}</h1>
${content}
<hr style="margin:24px 0;border:0;border-top:1px solid #e4e4e7;" />
<p style="font-size:11px;color:#71717a;margin:0;">
Karbé · <a href="${SITE_URL}" style="color:#71717a;">${SITE_URL}</a><br/>
Cet email a été envoyé suite à une action sur votre compte. Si ce n'est pas vous, ignorez-le.
</p>
</div>
</body></html>`;
}
export async function sendSignupWelcome(to: string, firstName: string): Promise<void> {
await sendEmail({
to,
subject: "Bienvenue sur Karbé",
html: wrap(
`Bienvenue ${firstName} !`,
`<p>Votre compte Karbé est créé. Vous pouvez désormais réserver un séjour ou, si vous êtes hôte, publier votre carbet.</p>
<p><a href="${SITE_URL}/carbets" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Découvrir les carbets</a></p>`,
),
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<void> {
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",
`<p>Bonjour ${firstName},</p>
<p>Votre demande de réservation pour <strong>${carbetTitle}</strong> a bien été enregistrée :</p>
<ul>
<li>Arrivée : ${fmt(startDate)}</li>
<li>Départ : ${fmt(endDate)}</li>
<li>Montant : ${Number(amount).toFixed(2)} ${currency}</li>
</ul>
<p>Vous recevrez un nouvel email dès que l'hôte ou l'équipe Karbé confirmera votre séjour.</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
),
});
}
export async function sendBookingRequestToOwner(
to: string,
ownerFirstName: string,
bookingId: string,
carbetTitle: string,
tenantName: string,
startDate: Date,
endDate: Date,
): Promise<void> {
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",
`<p>Bonjour ${ownerFirstName},</p>
<p><strong>${tenantName}</strong> souhaite réserver <strong>${carbetTitle}</strong> :</p>
<ul>
<li>Du ${fmt(startDate)} au ${fmt(endDate)}</li>
</ul>
<p>Connectez-vous à votre espace hôte pour confirmer ou refuser.</p>
<p><a href="${SITE_URL}/espace-hote" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Mon espace hôte</a></p>`,
),
});
}
export async function sendBookingConfirmed(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
startDate: Date,
endDate: Date,
): Promise<void> {
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é",
`<p>Bonjour ${firstName},</p>
<p>Votre réservation pour <strong>${carbetTitle}</strong> du ${fmt(startDate)} au ${fmt(endDate)} est confirmée.</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="display:inline-block;background:#16a34a;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Voir ma réservation</a></p>`,
),
});
}
export async function sendPasswordReset(
to: string,
resetUrl: string,
): Promise<void> {
await sendEmail({
to,
subject: "Réinitialisation de votre mot de passe Karbé",
html: wrap(
"Réinitialiser votre mot de passe",
`<p>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) :</p>
<p><a href="${resetUrl}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Réinitialiser mon mot de passe</a></p>
<p style="font-size:12px;color:#71717a;">Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.</p>`,
),
text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,
bookingId: string,
carbetTitle: string,
amount: string,
currency: string,
): Promise<void> {
await sendEmail({
to,
subject: `Remboursement traité — ${carbetTitle}`,
html: wrap(
"Remboursement en cours",
`<p>Bonjour ${firstName},</p>
<p>Votre réservation pour <strong>${carbetTitle}</strong> a été annulée et le remboursement de <strong>${Number(amount).toFixed(2)} ${currency}</strong> est en cours de traitement par Stripe (3 à 5 jours ouvrés).</p>
<p><a href="${SITE_URL}/reservations/${bookingId}" style="color:#18181b;">Détails de la réservation</a></p>`,
),
});
}